mslxdff 0.1.199 → 0.1.203
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/README.md +3 -3
- package/bin/mslxdff.js +1 -1
- package/package.json +38 -38
- package/plugins/README.md +14 -14
- package/plugins/prefer-model.mjs.example +14 -14
- package/src/anthropic/count-tokens.js +61 -61
- package/src/anthropic/request-body.js +126 -126
- package/src/anthropic/shared.js +18 -18
- package/src/anthropic/sse-aggregate.js +56 -56
- package/src/anthropic/translate.js +287 -287
- package/src/anthropic/web-search.js +361 -361
- package/src/api-kind.js +31 -31
- package/src/autostart.js +288 -288
- package/src/bench/probe.js +1 -1
- package/src/bench/runner.js +1 -1
- package/src/bench/via-probe.js +1 -1
- package/src/bench/via.js +144 -144
- package/src/bench/workbuddy-bench.js +1 -1
- package/src/chat/config.js +11 -11
- package/src/chat/cooling.js +81 -81
- package/src/chat/direct.js +58 -58
- package/src/chat/index.js +6 -6
- package/src/chat/spinner.js +38 -38
- package/src/chat/sse.js +69 -69
- package/src/chat/stats.js +247 -247
- package/src/chat/store.js +48 -48
- package/src/chat/tool-handlers.js +67 -67
- package/src/chat/tools.js +1 -1
- package/src/chat/upstream.js +59 -59
- package/src/chat-pipeline/auto-race.js +158 -158
- package/src/chat-pipeline/empty-turn.js +147 -147
- package/src/chat-pipeline/engine.js +17 -17
- package/src/chat-pipeline/serial-trial.js +283 -283
- package/src/chat-pipeline/terminal-verdict.js +147 -147
- package/src/chooser.js +54 -54
- package/src/claude-desktop/apply.js +101 -101
- package/src/claude-desktop/context1m.js +38 -38
- package/src/claude-desktop/doctor.js +146 -146
- package/src/claude-desktop/slots.js +152 -152
- package/src/cli/bootstrap.js +1 -1
- package/src/cli/commands/claude-desktop.js +284 -284
- package/src/cli/commands/daemon.js +231 -231
- package/src/cli/commands/model/list-live.js +22 -22
- package/src/cli/commands/model/list-providers.js +76 -76
- package/src/cli/commands/model/list-render.js +77 -77
- package/src/cli/commands/model/list-sort.js +52 -52
- package/src/cli/commands/model/list-tty.js +76 -76
- package/src/cli/commands/model/list.js +166 -166
- package/src/cli/commands/model/live-models.js +24 -24
- package/src/cli/commands/model/stats.js +43 -43
- package/src/cli/commands/model/status.js +47 -47
- package/src/cli/commands/model.js +51 -51
- package/src/cli/commands/provider/add.js +108 -108
- package/src/cli/commands/provider/allowlist.js +99 -99
- package/src/cli/commands/provider/cline-free.js +99 -99
- package/src/cli/commands/provider/cline-login.js +218 -218
- package/src/cli/commands/provider/cline-quota.js +65 -65
- package/src/cli/commands/provider/codearts-login.js +45 -45
- package/src/cli/commands/provider/config.js +119 -119
- package/src/cli/commands/provider/globalqwenwork-login.js +126 -126
- package/src/cli/commands/provider/keys-guard.js +50 -50
- package/src/cli/commands/provider/keys.js +161 -161
- package/src/cli/commands/provider/qoder-checkin.js +81 -81
- package/src/cli/commands/provider/qoder-login.js +102 -102
- package/src/cli/commands/provider/raccoon-login.js +180 -180
- package/src/cli/commands/provider/raccoon-password.js +162 -162
- package/src/cli/commands/provider/raccoon-quota.js +109 -109
- package/src/cli/commands/provider/traework-checkin.js +144 -144
- package/src/cli/commands/provider/traework-login.js +164 -164
- package/src/cli/commands/provider/workbuddy-login.js +153 -153
- package/src/cli/commands/provider/zcode-login.js +77 -77
- package/src/cli/commands/provider/zcode-quota.js +55 -55
- package/src/cli/commands/provider.js +1 -1
- package/src/cli/commands/stats.js +149 -149
- package/src/cli/commands/system.js +261 -261
- package/src/cli/commands/timezone.js +47 -47
- package/src/cli/commands/use-group.js +60 -60
- package/src/cli/commands/workbuddy.js +189 -189
- package/src/cli/format.js +141 -141
- package/src/cli/group-helpers.js +70 -70
- package/src/cli/help.js +80 -80
- package/src/cli/index.js +70 -70
- package/src/cli/interactive.js +121 -121
- package/src/cli/join-core.js +99 -99
- package/src/cli/join-wizard.js +175 -175
- package/src/cli/policy.js +136 -136
- package/src/cli/provider-row.js +126 -126
- package/src/cli/util.js +24 -24
- package/src/compat.js +53 -53
- package/src/events.js +38 -38
- package/src/free-lane.js +131 -131
- package/src/free-watcher.js +63 -63
- package/src/groups.js +262 -262
- package/src/logs.js +195 -195
- package/src/metrics.js +62 -62
- package/src/model-capabilities/context-lookup.js +65 -65
- package/src/model-capabilities/merge.js +142 -142
- package/src/model-trace.js +187 -187
- package/src/models.js +1 -1
- package/src/opencode-identity.js +72 -72
- package/src/peers.js +254 -251
- package/src/plugins.js +107 -107
- package/src/providers/base.js +164 -164
- package/src/providers/classify.js +22 -22
- package/src/providers/cline/allowlist-sync.js +93 -93
- package/src/providers/cline/free-catalog.js +71 -71
- package/src/providers/cline/headers.js +37 -37
- package/src/providers/cline/index.js +130 -130
- package/src/providers/cline/models.js +150 -150
- package/src/providers/cline/usage.js +274 -274
- package/src/providers/codearts/auth-pool.js +148 -148
- package/src/providers/codearts/concurrency.js +34 -34
- package/src/providers/codearts/const.js +65 -65
- package/src/providers/codearts/dpop.js +95 -95
- package/src/providers/codearts/index.js +175 -175
- package/src/providers/codearts/login.js +214 -214
- package/src/providers/codearts/models.js +182 -182
- package/src/providers/codearts/sign.js +96 -96
- package/src/providers/codearts/sse.js +204 -204
- package/src/providers/codearts/stream.js +209 -209
- package/src/providers/codearts/sts.js +124 -124
- package/src/providers/codearts.js +1 -1
- package/src/providers/dispatcher.js +146 -146
- package/src/providers/generic.js +156 -156
- package/src/providers/globalqwenwork/account-store.js +141 -141
- package/src/providers/globalqwenwork/constants.js +73 -73
- package/src/providers/globalqwenwork/crypto.js +2 -2
- package/src/providers/globalqwenwork/http.js +2 -2
- package/src/providers/globalqwenwork/index.js +331 -331
- package/src/providers/globalqwenwork/payload.js +147 -147
- package/src/providers/globalqwenwork/rsa.js +2 -2
- package/src/providers/globalqwenwork/sse.js +2 -2
- package/src/providers/globalqwenwork/stream.js +2 -2
- package/src/providers/globalqwenwork.js +1 -1
- package/src/providers/globalworkbuddy/account-store.js +198 -198
- package/src/providers/globalworkbuddy/auth.js +97 -97
- package/src/providers/globalworkbuddy/catalog.js +138 -138
- package/src/providers/globalworkbuddy/constants.js +73 -73
- package/src/providers/globalworkbuddy/device-auth.js +120 -120
- package/src/providers/globalworkbuddy/errors.js +98 -98
- package/src/providers/globalworkbuddy/headers.js +89 -89
- package/src/providers/globalworkbuddy/models.js +96 -96
- package/src/providers/globalworkbuddy/payload.js +80 -80
- package/src/providers/index.js +5 -5
- package/src/providers/model-id.js +172 -172
- package/src/providers/opencode.js +19 -19
- package/src/providers/openrouter.js +31 -31
- package/src/providers/qoder/account-store.js +106 -106
- package/src/providers/qoder/aggregate.js +60 -60
- package/src/providers/qoder/baseprompt.json +591 -591
- package/src/providers/qoder/checkin.js +160 -160
- package/src/providers/qoder/constants.js +44 -44
- package/src/providers/qoder/encode.js +41 -41
- package/src/providers/qoder/fingerprint.js +38 -38
- package/src/providers/qoder/models.js +77 -77
- package/src/providers/qoder/oauth.js +84 -84
- package/src/providers/qoder/payload.js +162 -162
- package/src/providers/qoder/request.js +27 -27
- package/src/providers/qoder/session.js +134 -134
- package/src/providers/qoder/sticky.js +38 -38
- package/src/providers/qoder/stream.js +116 -116
- package/src/providers/qoder.js +1 -1
- package/src/providers/raccoon/account-store.js +216 -216
- package/src/providers/raccoon/auth.js +67 -67
- package/src/providers/raccoon/chat.js +228 -228
- package/src/providers/raccoon/const.js +180 -165
- package/src/providers/raccoon/credits.js +185 -175
- package/src/providers/raccoon/crypto.js +43 -0
- package/src/providers/raccoon/envelope.js +31 -31
- package/src/providers/raccoon/headers.js +45 -45
- package/src/providers/raccoon/index.js +216 -216
- package/src/providers/raccoon/login.js +279 -279
- package/src/providers/raccoon/models.js +160 -160
- package/src/providers/raccoon/password-login.js +102 -87
- package/src/providers/raccoon/qr-codewords.js +115 -115
- package/src/providers/raccoon/qr.js +236 -236
- package/src/providers/raccoon/sse.js +135 -135
- package/src/providers/responses-channel.js +120 -120
- package/src/providers/share-keys.js +53 -53
- package/src/providers/traework/account-store.js +234 -234
- package/src/providers/traework/chat.js +217 -217
- package/src/providers/traework/checkin.js +77 -77
- package/src/providers/traework/constants.js +41 -41
- package/src/providers/traework/errors.js +52 -52
- package/src/providers/traework/headers.js +79 -79
- package/src/providers/traework/index.js +132 -132
- package/src/providers/traework/market-identity.js +116 -116
- package/src/providers/traework/models.js +98 -98
- package/src/providers/traework/payload.js +86 -86
- package/src/providers/traework/sse.js +184 -184
- package/src/providers/traework/token.js +52 -52
- package/src/providers/traework.js +2 -2
- package/src/providers/workbuddy/account-store.js +186 -186
- package/src/providers/workbuddy/auth.js +158 -158
- package/src/providers/workbuddy/balance.js +86 -86
- package/src/providers/workbuddy/cat-travel.js +125 -125
- package/src/providers/workbuddy/checkin.js +75 -75
- package/src/providers/workbuddy/desktop-info.js +78 -78
- package/src/providers/workbuddy/growth-api.js +67 -67
- package/src/providers/workbuddy/growth-plans.js +63 -63
- package/src/providers/workbuddy/growth.js +181 -181
- package/src/providers/workbuddy/index.js +217 -217
- package/src/providers/workbuddy/models.js +126 -126
- package/src/providers/workbuddy/payload.js +141 -141
- package/src/providers/workbuddy/rotation-log.js +83 -83
- package/src/providers/workbuddy/sanitize-tools.js +61 -61
- package/src/providers/workbuddy/sdk-chat.js +26 -26
- package/src/providers/workbuddy-balance.js +78 -78
- package/src/providers/zcode/account-store.js +129 -129
- package/src/providers/zcode/auth.js +28 -28
- package/src/providers/zcode/chat.js +179 -179
- package/src/providers/zcode/const.js +54 -54
- package/src/providers/zcode/context-shape.js +162 -162
- package/src/providers/zcode/headers.js +93 -93
- package/src/providers/zcode/index.js +124 -124
- package/src/providers/zcode/models.js +65 -65
- package/src/providers/zcode/oauth.js +120 -120
- package/src/providers/zcode/quota.js +176 -176
- package/src/providers/zcode/sse.js +196 -196
- package/src/readline-compat.js +25 -25
- package/src/responses/translate.js +311 -311
- package/src/routes/chat/broadband-handler.js +72 -72
- package/src/routes/chat/empty-turn-report.js +31 -31
- package/src/routes/chat/exhausted-handler.js +87 -87
- package/src/routes/chat/hedge-handler.js +130 -130
- package/src/routes/chat/index.js +1 -1
- package/src/routes/chat/local-handler.js +63 -63
- package/src/routes/chat/peer-handler.js +81 -81
- package/src/routes/chat.js +3 -3
- package/src/routes/fallback.js +88 -88
- package/src/routes/groups-relay.js +214 -214
- package/src/routes/groups.js +122 -122
- package/src/routes/helpers.js +83 -83
- package/src/routes/index.js +147 -147
- package/src/routes/messages-route.js +365 -365
- package/src/routes/peers.js +23 -1
- package/src/routes/relay-queue.js +181 -181
- package/src/routes/relay.js +60 -60
- package/src/routes/responses-route.js +213 -213
- package/src/routes/stream-body.js +84 -84
- package/src/routes/stream-hold.js +337 -337
- package/src/routes/stream-scan.js +366 -366
- package/src/routes.js +3 -3
- package/src/runtime/auto-update.js +113 -113
- package/src/runtime/bootstrap.js +8 -0
- package/src/runtime/broadband-stream.js +79 -79
- package/src/runtime/broadband.js +103 -103
- package/src/runtime/group-sync.js +81 -81
- package/src/runtime/keep-warm.js +120 -0
- package/src/runtime/lifecycle-forensics.js +116 -116
- package/src/runtime/lifecycle-log.js +87 -87
- package/src/runtime/log-stamp.js +73 -73
- package/src/runtime/proc-info.js +137 -137
- package/src/runtime/qoder-checkin.js +93 -93
- package/src/runtime/raccoon-checkin.js +126 -126
- package/src/runtime/server-lifecycle.js +189 -189
- package/src/runtime/traework-checkin.js +118 -118
- package/src/runtime/workbuddy-checkin.js +120 -120
- package/src/runtime/workbuddy-growth.js +133 -133
- package/src/server.js +7 -0
- package/src/state/facade.js +58 -58
- package/src/state/memory.js +162 -162
- package/src/state/merge.js +25 -25
- package/src/state/migrations/cline-unify.js +94 -94
- package/src/state/migrations.js +33 -33
- package/src/state/persist.js +42 -42
- package/src/state/provider-config.js +142 -142
- package/src/state/schemas/group.js +31 -31
- package/src/state/schemas/model.js +94 -94
- package/src/state/schemas/peer.js +31 -31
- package/src/state/schemas/port.js +10 -10
- package/src/state/schemas/provider.js +202 -202
- package/src/state/schemas/timezone.js +70 -70
- package/src/state/schemas/token.js +48 -48
- package/src/state/schemas/use-group.js +102 -102
- package/src/state/store.js +176 -176
- package/src/state.js +1 -1
- package/src/sync-claude-desktop.js +234 -234
- package/src/sync-claude.js +152 -152
- package/src/sync-codex.js +116 -116
- package/src/sync-workbuddy.js +158 -158
- package/src/talk-full-redact.js +129 -129
- package/src/talk-full.js +131 -131
- package/src/talk-log.js +257 -257
- package/src/text-tokens.js +52 -52
- package/src/thinking-effort.js +67 -67
- package/src/time.js +106 -106
- package/src/timeline.js +26 -26
- package/src/transport/index.js +1 -1
- package/src/upstream-engine/index.js +74 -72
- package/src/upstream-engine/mode.js +15 -15
- package/src/upstream-engine/sdk/attempt.js +221 -221
- package/src/upstream-engine/sdk/chat.js +28 -28
- package/src/upstream-engine/sdk/convert.js +192 -192
- package/src/upstream-engine/sdk/diagnose.js +62 -62
- package/src/upstream-engine/sdk/dispatch.js +55 -55
- package/src/upstream-engine/sdk/responses.js +176 -176
- package/src/upstream-engine/sdk/sse.js +166 -166
- package/src/upstream-responses.js +305 -305
- package/src/upstream.js +53 -1
- package/src/usage/record.js +106 -106
- package/src/usage/report.js +195 -195
- package/src/v6pool/claim.js +157 -157
- package/src/v6pool/config.js +65 -65
- package/src/v6pool/detect.js +130 -130
- package/src/v6pool/index.js +152 -152
- package/src/v6pool/prefix.js +140 -140
- package/src/v6pool/probe.js +40 -40
- package/src/v6pool/report.js +104 -104
- package/src/v6pool/rotate.js +263 -239
- package/src/warp-pool/config.js +74 -74
- package/src/warp-pool/health.js +106 -106
- package/src/warp-pool/identity.js +141 -141
- package/src/warp-pool/index.js +358 -358
- package/src/warp-pool/keys.js +29 -29
- package/src/warp-pool/nodes.js +95 -95
- package/src/warp-pool/ports.js +160 -160
- package/src/warp-pool/register.js +113 -113
- package/src/warp-pool/report.js +232 -232
- package/src/warp-pool/revive.js +168 -168
- package/src/warp-pool/rotate.js +214 -214
- package/src/warp-pool/strategy.js +43 -43
- package/src/warp-pool/verdict.js +69 -69
- package/src/warp-pool/wireproxy.js +162 -162
- package/docs/cli_help_mini.md +0 -146
- package/src/providers/AGENTS.md +0 -49
- package/src/routes/AGENTS.md +0 -37
|
@@ -1,366 +1,366 @@
|
|
|
1
|
-
// relay 的观测累加器:把流式 SSE chunk / 非流式 JSON 体读成 detail 上的计数事实。
|
|
2
|
-
// 抽自 src/routes/stream.js(单文件 20KB 硬门,见 AGENTS.md「先拆后写」),行为与抽出前逐字一致。
|
|
3
|
-
// 纯函数契约:只读写传入的 detail,不碰 res / 定时器 / 闸门 / 网络,因此可离线单测。
|
|
4
|
-
// 语义钉子:detail.chars 只算正文,思考内容另计(reasoningChars)——两者相加会重复计数。
|
|
5
|
-
import { extractUsageFromJson } from "../metrics.js";
|
|
6
|
-
import { talkLogEnabled } from "../talk-log.js"; // 建桶判据之一(两模块都是按调用读 env,无 import 期冻结)
|
|
7
|
-
import { agentLoopEnabled, talkCapChars } from "../talk-full.js"; // 上限单一真相:本文件不得再写第二份数字
|
|
8
|
-
|
|
9
|
-
// 响应正文捕获桶(talk log + agent 回路共用一只):只有 relay 建了 detail.talk 才累积,未开启时零开销。
|
|
10
|
-
// 口径与 chars/reasoningChars 同源(读 delta,不读聚合帧),区别是这里存原文而不是只计数。
|
|
11
|
-
// 分帧口径(openspec fix-talk-capture-frame-loss):**按行不按 chunk** —— 未以 `\n` 收尾的尾行留在
|
|
12
|
-
// talk.remain 里等下个工作拼回,凑齐的完整 `data:` 行才 parse;流收场由消费方调 captureTalkFlush
|
|
13
|
-
// 回收缺尾换行的末行。历史口径「帧被 chunk 切断即弃(研究用途可接受)」已废——它让 talk/talkfull
|
|
14
|
-
// 的 tool arguments 随机少 1~10 字符(现网取证 43% 路径落空),误导模型侧归因。
|
|
15
|
-
// 不可逆丢弃(切点落在多字节字符内部的拼回行、超限半行)不静默:桶上 dropped 计数显式可见,
|
|
16
|
-
// 语料 meta.droppedFrames / talk 头行 dropped= 透出——「看起来完整其实吃字」比缺失更危险(review P1-1)。
|
|
17
|
-
const TALK_REMAIN_MAX = 2_000_000; // 半行缓冲上限(UTF-16 字符数,≈4MB):远大于任何流式 delta 单行;超限弃半行并显式记 dropped(review P2-5)
|
|
18
|
-
// 上限真相是 talkCapChars()(MSLXDFF_TALK_CAP_CHARS > full 开 200 万 > full 关 40 万);
|
|
19
|
-
// 下面的常数只是 createTalkBucket 未传 cap 时的历史缺省,必须与「full 关」档逐字相同(有测试锁死)。
|
|
20
|
-
const TALK_CAP_DEFAULT = 400_000;
|
|
21
|
-
|
|
22
|
-
function talkPush(talk, dst, v) {
|
|
23
|
-
if (typeof v !== "string" || !v) return;
|
|
24
|
-
if (talk.n >= (talk.cap ?? TALK_CAP_DEFAULT)) { talk.capped = true; return; } // 撞顶只置位不静默:读侧靠它标 truncated
|
|
25
|
-
talk.n += v.length;
|
|
26
|
-
talk[dst].push(v);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// 流式 tool_calls 是分片到达的(index 定位、arguments 逐段拼),按 index 合回完整调用才看得懂。
|
|
30
|
-
function talkMergeToolCalls(talk, tcs) {
|
|
31
|
-
for (const tc of tcs) {
|
|
32
|
-
const i = Number.isInteger(tc?.index) ? tc.index : talk.tools.length;
|
|
33
|
-
let slot = talk.tools[i];
|
|
34
|
-
if (!slot) { slot = { id: "", type: "function", function: { name: "", arguments: "" } }; talk.tools[i] = slot; }
|
|
35
|
-
if (tc.id) slot.id = tc.id;
|
|
36
|
-
const nm = tc.function?.name;
|
|
37
|
-
const ar = tc.function?.arguments;
|
|
38
|
-
if (typeof nm === "string" && nm && !slot.function.name.includes(nm)) slot.function.name += nm;
|
|
39
|
-
if (typeof ar === "string" && ar) slot.function.arguments += ar;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** 新建累积桶(n/capped 防超长生成把内存吃穿)。cap = 字符上限,未传/非法 → 400_000(现状逐字不变)。
|
|
44
|
-
* remain = 跨 chunk 半行缓冲(openspec fix-talk-capture-frame-loss 行缓冲分帧);dropped = 不可逆丢弃帧计数(P1-1 显式读数)。 */
|
|
45
|
-
export function createTalkBucket(cap) {
|
|
46
|
-
const c = Math.floor(Number(cap));
|
|
47
|
-
const use = Number.isFinite(c) && c > 0 ? c : TALK_CAP_DEFAULT;
|
|
48
|
-
return { reasoning: [], content: [], tools: [], n: 0, capped: false, cap: use, remain: "", dropped: 0 };
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* 「该不该建桶 + 建多大」的唯一判据,收在这里是为了给 stream.js 让体积(20KB 硬门)。
|
|
53
|
-
* talk.log 与 agent 回路任一开启都必须建桶:只认 talkLogEnabled() 会让「talk.log 关 + full 开」
|
|
54
|
-
* 的响应正文全丢(detail.talk 为 null,capture 全程记到空气)。上限取 talkCapChars() 单一真相。
|
|
55
|
-
*/
|
|
56
|
-
export function createTalkBucketIfEnabled() {
|
|
57
|
-
return talkLogEnabled() || agentLoopEnabled() ? createTalkBucket(talkCapChars()) : null;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** 完整单行消费(talk 桶的唯一落点,从原 captureTalkSse 循环体抽出,口径逐字不变)。glued=该行由跨 chunk 拼回。 */
|
|
61
|
-
function talkConsumeLine(talk, raw, glued = false) {
|
|
62
|
-
if (glued && raw.includes("\uFFFD")) { talk.dropped = (Number(talk.dropped) || 0) + 1; return; } // 字节级切点:解码损伤不可逆,整行弃但必须留数(根因在 stream.js chunkText 逐 chunk 无状态解码,真修另起 change)
|
|
63
|
-
const t = raw.trim();
|
|
64
|
-
if (!t.startsWith("data:")) return;
|
|
65
|
-
const d = t.slice(5).trim();
|
|
66
|
-
if (!d || d === "[DONE]") return;
|
|
67
|
-
let j = null;
|
|
68
|
-
try { j = JSON.parse(d); } catch { return; }
|
|
69
|
-
const c0 = Array.isArray(j?.choices) ? j.choices[0] : null;
|
|
70
|
-
if (!c0) return;
|
|
71
|
-
for (const src of [c0.delta, c0.message]) {
|
|
72
|
-
if (!src) continue;
|
|
73
|
-
talkPush(talk, "reasoning", src.reasoning_content ?? src.reasoning);
|
|
74
|
-
talkPush(talk, "content", src.content);
|
|
75
|
-
if (Array.isArray(src.tool_calls)) talkMergeToolCalls(talk, src.tool_calls);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// 已见工具调用 index:按 detail 对象存进 WeakMap,既不污染 detail(它会被序列化进 events/模型日志,
|
|
80
|
-
// 多一个属性就是日志噪音),也不随 detail 的展开/复制丢失统计口径。
|
|
81
|
-
const tcIndexes = new WeakMap();
|
|
82
|
-
|
|
83
|
-
/** 工具调用去重计数:同一 `index` 的多个分片只算一次;分片缺 `index` 时退回 0。 */
|
|
84
|
-
function countToolCalls(detail, txt) {
|
|
85
|
-
if (!detail || typeof detail !== "object") return;
|
|
86
|
-
let seen = tcIndexes.get(detail);
|
|
87
|
-
if (!seen) { seen = new Set(); tcIndexes.set(detail, seen); }
|
|
88
|
-
const re = /"tool_calls"\s*:\s*\[\s*\{/g;
|
|
89
|
-
let m;
|
|
90
|
-
while ((m = re.exec(txt))) {
|
|
91
|
-
// 从 tool_calls 数组起点往后取第一个 index(choices[].index 在此位置之前,不会误取)
|
|
92
|
-
const after = txt.slice(m.index + m[0].length - 1);
|
|
93
|
-
const im = after.match(/"index"\s*:\s*(\d+)/);
|
|
94
|
-
seen.add(im ? Number(im[1]) : 0);
|
|
95
|
-
}
|
|
96
|
-
if (seen.size) detail.toolCalls = seen.size;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** 逐 chunk 累积一轮对话的思考/正文/工具调用。talk 为空 = 未开启,直接返回。跨 chunk 行缓冲见文件头。 */
|
|
100
|
-
export function captureTalkSse(talk, txt) {
|
|
101
|
-
if (!talk) return;
|
|
102
|
-
if (talk.remain && talk.remain.length > TALK_REMAIN_MAX) { talk.remain = ""; talk.dropped = (Number(talk.dropped) || 0) + 1; } // 拼接前丢弃超限半行:内存不尖峰(review P2-5)且显式记数;本轮正常数据照常消费
|
|
103
|
-
const hadRemain = Boolean(talk.remain);
|
|
104
|
-
const buf = (hadRemain ? talk.remain : "") + String(txt ?? "");
|
|
105
|
-
talk.remain = "";
|
|
106
|
-
if (!buf) return;
|
|
107
|
-
const lines = buf.split("\n");
|
|
108
|
-
if (!buf.endsWith("\n")) talk.remain = lines.pop() ?? "";
|
|
109
|
-
for (let i = 0; i < lines.length; i++) talkConsumeLine(talk, lines[i], hadRemain && i === 0);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** 流收场回收:把缓冲里缺尾换行的末行按同一单行口径入桶(截断流不静默吞末帧)。幂等;无桶/空缓冲零副作用。 */
|
|
113
|
-
export function captureTalkFlush(talk) {
|
|
114
|
-
if (!talk || !talk.remain) return;
|
|
115
|
-
const raw = talk.remain;
|
|
116
|
-
talk.remain = "";
|
|
117
|
-
talkConsumeLine(talk, raw, true);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/** 非流式:整块 message 直接入桶(tool_calls 参数本来就是完整的)。 */
|
|
121
|
-
export function captureTalkMessage(talk, msg) {
|
|
122
|
-
if (!talk || !msg) return;
|
|
123
|
-
talkPush(talk, "reasoning", msg.reasoning_content ?? msg.reasoning);
|
|
124
|
-
talkPush(talk, "content", msg.content);
|
|
125
|
-
if (Array.isArray(msg.tool_calls)) talkMergeToolCalls(talk, msg.tool_calls);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** 透传兜底:无 chat 帧可解析(纯文本/未知形状)时把原文当正文收下,别让对话日志留空洞。 */
|
|
129
|
-
export function captureTalkFallback(talk, text) {
|
|
130
|
-
if (!talk || !text) return;
|
|
131
|
-
if (talk.reasoning.length || talk.content.length || talk.tools.length) return;
|
|
132
|
-
if (String(text).includes('"choices"')) return;
|
|
133
|
-
talkPush(talk, "content", String(text));
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// 判据必须跑在**完整行**上:TCP 会把一帧切两块,`"finish_reason"`/`"content"` 被切在中间时正则永不命中
|
|
137
|
-
// ⇒ sawFinishReason 恒 null(正常结束的流被 endTruncatedStream 误判成截断,向客户端写假 STREAM_TRUNCATED)、
|
|
138
|
-
// chars 归零(chatShaped 为真时正好命中空轮判据)。与 BUG-24/25 同型:那次是终止标记被切块,这次是观测层缺行缓冲。
|
|
139
|
-
const scanRemain = new WeakMap();
|
|
140
|
-
const SCAN_REMAIN_MAX = 1_000_000; // 半行上限:超限按完整行处理,宁可错判也不无界吃内存
|
|
141
|
-
|
|
142
|
-
/** 流收场回收:末尾无换行的残行按同一口径扫一次,否则末帧的 finish_reason 永远读不到。幂等。 */
|
|
143
|
-
export function flushScanRemainder(detail) {
|
|
144
|
-
if (!detail || typeof detail !== "object") return;
|
|
145
|
-
const rest = scanRemain.get(detail);
|
|
146
|
-
if (!rest) return;
|
|
147
|
-
scanRemain.delete(detail);
|
|
148
|
-
scanCompleteText(detail, rest);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// 逐 SSE chunk 扫描:done/finish_reason/chat 形状/工具调用/usage/正文字符
|
|
152
|
-
export function scanSseChunk(detail, txt) {
|
|
153
|
-
if (detail.talk) { try { captureTalkSse(detail.talk, txt); } catch {} } // 纯旁路红线:捕获异常绝不外溢成转发失败(review P2-7:旧调用点裸奔在 try 外只是口头契约)
|
|
154
|
-
let buf = (scanRemain.get(detail) || "") + String(txt ?? "");
|
|
155
|
-
const rest = buf.slice(buf.lastIndexOf("\n") + 1);
|
|
156
|
-
if (rest.length > SCAN_REMAIN_MAX) { scanRemain.set(detail, ""); }
|
|
157
|
-
else { buf = buf.slice(0, buf.length - rest.length); scanRemain.set(detail, rest); }
|
|
158
|
-
if (buf) scanCompleteText(detail, buf);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function scanCompleteText(detail, txt) {
|
|
162
|
-
try {
|
|
163
|
-
if (txt.includes("[DONE]")) detail.sawDone = true;
|
|
164
|
-
const m = txt.match(/"finish_reason"\s*:\s*"([^"]+)"/);
|
|
165
|
-
if (m) detail.sawFinishReason = m[1];
|
|
166
|
-
// chat 形状证据:只有看得出是 chat 轮才配判空(非 chat SSE/JSON 透传是正式契约,不得误伤)
|
|
167
|
-
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;
|
|
168
|
-
// 工具调用计数(空数组不算):tool_calls 无正文是合法 agent 轮,
|
|
169
|
-
// 空转闸门必须豁免它,否则所有工具轮都会被误判为空轮——见 relay-pipeline 4b。
|
|
170
|
-
// 口径按 `index` 去重:流式 tool_calls 的 arguments 是**分片**到达的,每个分片都含
|
|
171
|
-
// `"tool_calls":[{"index":N,...` ⇒ 按命中次数累加会把一次调用计成 N 次(实测 4894/5999 帧命中,
|
|
172
|
-
// 虚高数千倍,且与 scanNonStreamBody 的 `.length` 口径不一致)。去重后与非流式同义。
|
|
173
|
-
countToolCalls(detail, txt);
|
|
174
|
-
// 尝试提取 usage(流式末帧):口径收口到 metrics.js,与未流式分支共用
|
|
175
|
-
if (txt.includes("\"usage\"") || txt.includes("\"prompt_tokens\"")) {
|
|
176
|
-
try {
|
|
177
|
-
const lines = txt.split("\n");
|
|
178
|
-
for (const line of lines) {
|
|
179
|
-
const t = line.trim();
|
|
180
|
-
if (!t.startsWith("data:")) continue;
|
|
181
|
-
const d = t.slice(5).trim();
|
|
182
|
-
if (d === "[DONE]" || !d) continue;
|
|
183
|
-
// 行级隔离:单行坏 JSON 不拖累同 chunk 其余行
|
|
184
|
-
try {
|
|
185
|
-
const j = JSON.parse(d);
|
|
186
|
-
if (!j || typeof j !== "object") continue;
|
|
187
|
-
const u = extractUsageFromJson(j);
|
|
188
|
-
if (u) detail.usage = u;
|
|
189
|
-
// 兜底 chars:从 choices 文本长度累加
|
|
190
|
-
const delta = j.choices?.[0]?.delta?.content || j.choices?.[0]?.message?.content || "";
|
|
191
|
-
if (delta) detail.chars += String(delta).length;
|
|
192
|
-
const rc = j.choices?.[0]?.delta?.reasoning_content || j.choices?.[0]?.delta?.reasoning
|
|
193
|
-
|| j.choices?.[0]?.message?.reasoning_content || j.choices?.[0]?.message?.reasoning || "";
|
|
194
|
-
if (rc) detail.reasoningChars = (detail.reasoningChars || 0) + String(rc).length;
|
|
195
|
-
} catch { /* 单行坏帧忽略 */ }
|
|
196
|
-
}
|
|
197
|
-
} catch {}
|
|
198
|
-
} else {
|
|
199
|
-
// 非 usage 的普通 delta 也累 chars
|
|
200
|
-
try {
|
|
201
|
-
const ms = txt.match(/"content"\s*:\s*"([^"]*)"/g);
|
|
202
|
-
if (ms) for (const mm of ms) {
|
|
203
|
-
const c = JSON.parse(`{${mm}}`);
|
|
204
|
-
if (c.content) detail.chars += String(c.content).length;
|
|
205
|
-
}
|
|
206
|
-
// 同一 chunk 里的思考增量:键名两种上游都见过(reasoning_content / reasoning)
|
|
207
|
-
const rs = txt.match(/"reasoning(?:_content)?"\s*:\s*"([^"]*)"/g);
|
|
208
|
-
if (rs) for (const mm of rs) {
|
|
209
|
-
const c = JSON.parse(`{${mm}}`);
|
|
210
|
-
const v = c.reasoning_content ?? c.reasoning;
|
|
211
|
-
if (v) detail.reasoningChars = (detail.reasoningChars || 0) + String(v).length;
|
|
212
|
-
}
|
|
213
|
-
} catch {}
|
|
214
|
-
}
|
|
215
|
-
} catch { /* ignore */ }
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// 非流式聚合 JSON 体:同源口径提 usage/chars/chat 形状/工具调用
|
|
219
|
-
export function scanNonStreamBody(detail, parsed) {
|
|
220
|
-
const u = extractUsageFromJson(parsed);
|
|
221
|
-
if (u) detail.usage = u;
|
|
222
|
-
if (parsed.choices?.[0]?.message?.content) detail.chars = String(parsed.choices[0].message.content).length;
|
|
223
|
-
else if (parsed.choices?.[0]?.text) detail.chars = String(parsed.choices[0].text).length;
|
|
224
|
-
// 思考内容另记(qoder 的 stream:false 出口把思考放在 message.reasoning_content)
|
|
225
|
-
const rc = parsed.choices?.[0]?.message?.reasoning_content || parsed.choices?.[0]?.message?.reasoning || "";
|
|
226
|
-
if (rc) detail.reasoningChars = (detail.reasoningChars || 0) + String(rc).length;
|
|
227
|
-
// 非流式同样只判 chat 形状:无 choices 的任意 JSON 是透传契约(chat-route 单测锁死),不得判空
|
|
228
|
-
if (Array.isArray(parsed?.choices)) detail.chatShaped = true;
|
|
229
|
-
const _tcList = parsed.choices?.[0]?.message?.tool_calls;
|
|
230
|
-
if (Array.isArray(_tcList) && _tcList.length) detail.toolCalls = _tcList.length;
|
|
231
|
-
if (detail.talk) captureTalkMessage(detail.talk, parsed.choices?.[0]?.message || (parsed.choices?.[0]?.text ? { content: parsed.choices[0].text } : null));
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// 上报锚点偏移:转发入口时刻 − 本次上游尝试起点(performance.now 同源)。
|
|
235
|
-
// 只服务上报时长(usage 行),四类保护闸门仍用 relay 自己的 t0,绝不引用它。
|
|
236
|
-
export function preflightMs(t0, attemptStartMs) {
|
|
237
|
-
// 锚点必须是与 t0 同源(performance.now)的有限 number;其余一律按「无锚点」降级为 0,
|
|
238
|
-
// 宁可退回修复前口径,也不能产出 NaN 或假高值
|
|
239
|
-
if (typeof attemptStartMs !== "number" || !Number.isFinite(attemptStartMs)) return 0;
|
|
240
|
-
const delta = Math.round(t0 - attemptStartMs);
|
|
241
|
-
return delta > 0 ? delta : 0;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* 空轮判据(纯函数,唯一真相):看得 chat 形状 + 零正文 + 零工具调用 + 收尾不是工具轮。
|
|
246
|
-
* stream.js 用它决定「这次要不要封口」,relay-pipeline 用它决定「交不交回重试」——两处不得各写一份。
|
|
247
|
-
* chatShaped 是前置证据:非 chat SSE / 无 choices 的透传是正式契约,一律放行不判空。
|
|
248
|
-
*/
|
|
249
|
-
export function isEmptyTurnDetail(d) {
|
|
250
|
-
if (!d || d.chatShaped !== true) return false;
|
|
251
|
-
if ((Number(d.chars) || 0) > 0 || (Number(d.toolCalls) || 0) > 0) return false;
|
|
252
|
-
if (["tool_calls", "function_call"].includes(d.sawFinishReason)) return false;
|
|
253
|
-
return true;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// ===== 从 stream.js 迁入(体积门:stream.js 承载 relay 本体,纯判定函数一律住这里)=====
|
|
257
|
-
|
|
258
|
-
// 错误包络 chunk 判定(纯函数):data 行 JSON 含顶层 .error 对象、或只有 finish_reason=error
|
|
259
|
-
// 的空帧,且整 chunk 无任何 content/tool_calls/reasoning 输出 → 返回错误摘要(建议暂扣),
|
|
260
|
-
// 否则返回 null(透传)。解析失败一律透传(默认保安全)。
|
|
261
|
-
// 背景:网关把上游失败包成 HTTP 200 SSE;暂扣后下游流式 UI 不再展示瞬时错误
|
|
262
|
-
//(如 Vertex 503 + google fallback 400),本轮走空转重试,客户端只看到最终结果。
|
|
263
|
-
// Note: 流内错误包络捕获与 retryAfterSeconds 错误直出 — 见 .agents/notes/implemented/bug-fix/2026-09-28-retry-after-surfacing-error.md
|
|
264
|
-
export function extractRetryAfterSec(obj) {
|
|
265
|
-
if (!obj) return null;
|
|
266
|
-
if (typeof obj === "number") return obj;
|
|
267
|
-
if (typeof obj === "string") {
|
|
268
|
-
const m = obj.match(/"retryAfterSeconds"\s*:\s*(\d+)/i) || obj.match(/\bretryAfterSeconds\s*[:=]\s*(\d+)/i);
|
|
269
|
-
if (m) return Number(m[1]);
|
|
270
|
-
try {
|
|
271
|
-
const parsed = JSON.parse(obj);
|
|
272
|
-
return extractRetryAfterSec(parsed);
|
|
273
|
-
} catch {}
|
|
274
|
-
}
|
|
275
|
-
if (typeof obj === "object") {
|
|
276
|
-
if (Number.isFinite(obj.retryAfterSeconds)) return Number(obj.retryAfterSeconds);
|
|
277
|
-
if (obj.message) {
|
|
278
|
-
const r = extractRetryAfterSec(obj.message);
|
|
279
|
-
if (r != null) return r;
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
return null;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
export function holdableChunk(txt) {
|
|
286
|
-
if (typeof txt !== "string" || !txt.includes("data:")) return null;
|
|
287
|
-
let sawData = false;
|
|
288
|
-
let sawEventError = false;
|
|
289
|
-
let retrySec = null;
|
|
290
|
-
const errs = [];
|
|
291
|
-
for (const rawLine of txt.split("\n")) {
|
|
292
|
-
const t = rawLine.trim();
|
|
293
|
-
if (t.startsWith("event:") && t.slice(6).trim() === "error") {
|
|
294
|
-
sawEventError = true;
|
|
295
|
-
continue;
|
|
296
|
-
}
|
|
297
|
-
if (!t.startsWith("data:")) continue;
|
|
298
|
-
const d = t.slice(5).trim();
|
|
299
|
-
if (!d || d === "[DONE]") continue;
|
|
300
|
-
let j = null;
|
|
301
|
-
try { j = JSON.parse(d); } catch { return null; }
|
|
302
|
-
if (!j || typeof j !== "object") return null;
|
|
303
|
-
sawData = true;
|
|
304
|
-
const c0 = (Array.isArray(j.choices) && j.choices[0]) || {};
|
|
305
|
-
const delta = c0.delta || {};
|
|
306
|
-
const msg = c0.message || {};
|
|
307
|
-
const out = delta.content || msg.content || delta.tool_calls || msg.tool_calls || delta.reasoning || msg.reasoning;
|
|
308
|
-
if (out && !(Array.isArray(out) && out.length === 0)) return null;
|
|
309
|
-
|
|
310
|
-
const r = extractRetryAfterSec(j);
|
|
311
|
-
if (r != null) {
|
|
312
|
-
retrySec = r;
|
|
313
|
-
continue;
|
|
314
|
-
}
|
|
315
|
-
if (j.error && typeof j.error === "object") {
|
|
316
|
-
const m = j.error.message || j.error.code || "";
|
|
317
|
-
if (m) errs.push(String(m).slice(0, 300));
|
|
318
|
-
} else if (sawEventError && j.message) {
|
|
319
|
-
errs.push(String(j.message).slice(0, 300));
|
|
320
|
-
} else if (c0.finish_reason === "error") {
|
|
321
|
-
errs.push("finish_reason=error 空帧");
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
if (!sawData) return null;
|
|
325
|
-
if (retrySec != null) return `上游供应商触发 retryAfterSeconds: ${retrySec} ,请等候重试`;
|
|
326
|
-
if (errs.length) return errs.join(" | ");
|
|
327
|
-
if (sawEventError) return "event: error 空帧";
|
|
328
|
-
return null;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
/**
|
|
332
|
-
* 「可撤销前缀」判据(纯函数):这个 chunk 里除空 delta 帧与 [DONE] 外不含任何信息吗?
|
|
333
|
-
* 只有答案为是才允许暂扣不写——透传是正式契约:解析失败的帧、看不懂的形状一律立即透传(与
|
|
334
|
-
* holdableChunk 同一口径:默认保安全,绝不因为想救空轮而扣掉本该原样转给客户端的字节)。
|
|
335
|
-
*/
|
|
336
|
-
export function isTrivialFrame(txt) {
|
|
337
|
-
if (typeof txt !== "string" || !txt.includes("data:")) return false;
|
|
338
|
-
let sawData = false;
|
|
339
|
-
for (const rawLine of txt.split("\n")) {
|
|
340
|
-
const t = rawLine.trim();
|
|
341
|
-
if (!t.startsWith("data:")) continue;
|
|
342
|
-
const d = t.slice(5).trim();
|
|
343
|
-
if (!d) continue;
|
|
344
|
-
if (d === "[DONE]") { sawData = true; continue; } // 结束符不含模型输出,可撤销
|
|
345
|
-
let j = null;
|
|
346
|
-
try { j = JSON.parse(d); } catch { return false; } // 解析失败 → 透传,绝不撤销
|
|
347
|
-
if (!j || typeof j !== "object" || Array.isArray(j)) return false;
|
|
348
|
-
// 白名单式可撤销:只认「choices 数组、且其中没有任何文本载荷」的 chat 帧。
|
|
349
|
-
// 其余形状(choices[].text、delta.text、顶层 usage、无 choices 的自定义帧……)一律算有信息 → 透传。
|
|
350
|
-
if (!Array.isArray(j.choices)) return false;
|
|
351
|
-
sawData = true;
|
|
352
|
-
if (j.choices.some((c) => c && typeof c === "object" && (c.text || c.message?.text))) return false;
|
|
353
|
-
for (const c of j.choices) {
|
|
354
|
-
if (!c || typeof c !== "object") return false;
|
|
355
|
-
for (const src of [c.delta, c.message]) {
|
|
356
|
-
if (!src || typeof src !== "object") continue;
|
|
357
|
-
for (const [k, v] of Object.entries(src)) {
|
|
358
|
-
if (k === "role" || k === "index" || k === "function_call_name") continue; // 无信息骨架字段
|
|
359
|
-
if (v == null || v === "") continue;
|
|
360
|
-
if (Array.isArray(v) ? v.length > 0 : true) return false; // 任何非空载荷都算产出
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
return sawData;
|
|
366
|
-
}
|
|
1
|
+
// relay 的观测累加器:把流式 SSE chunk / 非流式 JSON 体读成 detail 上的计数事实。
|
|
2
|
+
// 抽自 src/routes/stream.js(单文件 20KB 硬门,见 AGENTS.md「先拆后写」),行为与抽出前逐字一致。
|
|
3
|
+
// 纯函数契约:只读写传入的 detail,不碰 res / 定时器 / 闸门 / 网络,因此可离线单测。
|
|
4
|
+
// 语义钉子:detail.chars 只算正文,思考内容另计(reasoningChars)——两者相加会重复计数。
|
|
5
|
+
import { extractUsageFromJson } from "../metrics.js";
|
|
6
|
+
import { talkLogEnabled } from "../talk-log.js"; // 建桶判据之一(两模块都是按调用读 env,无 import 期冻结)
|
|
7
|
+
import { agentLoopEnabled, talkCapChars } from "../talk-full.js"; // 上限单一真相:本文件不得再写第二份数字
|
|
8
|
+
|
|
9
|
+
// 响应正文捕获桶(talk log + agent 回路共用一只):只有 relay 建了 detail.talk 才累积,未开启时零开销。
|
|
10
|
+
// 口径与 chars/reasoningChars 同源(读 delta,不读聚合帧),区别是这里存原文而不是只计数。
|
|
11
|
+
// 分帧口径(openspec fix-talk-capture-frame-loss):**按行不按 chunk** —— 未以 `\n` 收尾的尾行留在
|
|
12
|
+
// talk.remain 里等下个工作拼回,凑齐的完整 `data:` 行才 parse;流收场由消费方调 captureTalkFlush
|
|
13
|
+
// 回收缺尾换行的末行。历史口径「帧被 chunk 切断即弃(研究用途可接受)」已废——它让 talk/talkfull
|
|
14
|
+
// 的 tool arguments 随机少 1~10 字符(现网取证 43% 路径落空),误导模型侧归因。
|
|
15
|
+
// 不可逆丢弃(切点落在多字节字符内部的拼回行、超限半行)不静默:桶上 dropped 计数显式可见,
|
|
16
|
+
// 语料 meta.droppedFrames / talk 头行 dropped= 透出——「看起来完整其实吃字」比缺失更危险(review P1-1)。
|
|
17
|
+
const TALK_REMAIN_MAX = 2_000_000; // 半行缓冲上限(UTF-16 字符数,≈4MB):远大于任何流式 delta 单行;超限弃半行并显式记 dropped(review P2-5)
|
|
18
|
+
// 上限真相是 talkCapChars()(MSLXDFF_TALK_CAP_CHARS > full 开 200 万 > full 关 40 万);
|
|
19
|
+
// 下面的常数只是 createTalkBucket 未传 cap 时的历史缺省,必须与「full 关」档逐字相同(有测试锁死)。
|
|
20
|
+
const TALK_CAP_DEFAULT = 400_000;
|
|
21
|
+
|
|
22
|
+
function talkPush(talk, dst, v) {
|
|
23
|
+
if (typeof v !== "string" || !v) return;
|
|
24
|
+
if (talk.n >= (talk.cap ?? TALK_CAP_DEFAULT)) { talk.capped = true; return; } // 撞顶只置位不静默:读侧靠它标 truncated
|
|
25
|
+
talk.n += v.length;
|
|
26
|
+
talk[dst].push(v);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 流式 tool_calls 是分片到达的(index 定位、arguments 逐段拼),按 index 合回完整调用才看得懂。
|
|
30
|
+
function talkMergeToolCalls(talk, tcs) {
|
|
31
|
+
for (const tc of tcs) {
|
|
32
|
+
const i = Number.isInteger(tc?.index) ? tc.index : talk.tools.length;
|
|
33
|
+
let slot = talk.tools[i];
|
|
34
|
+
if (!slot) { slot = { id: "", type: "function", function: { name: "", arguments: "" } }; talk.tools[i] = slot; }
|
|
35
|
+
if (tc.id) slot.id = tc.id;
|
|
36
|
+
const nm = tc.function?.name;
|
|
37
|
+
const ar = tc.function?.arguments;
|
|
38
|
+
if (typeof nm === "string" && nm && !slot.function.name.includes(nm)) slot.function.name += nm;
|
|
39
|
+
if (typeof ar === "string" && ar) slot.function.arguments += ar;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 新建累积桶(n/capped 防超长生成把内存吃穿)。cap = 字符上限,未传/非法 → 400_000(现状逐字不变)。
|
|
44
|
+
* remain = 跨 chunk 半行缓冲(openspec fix-talk-capture-frame-loss 行缓冲分帧);dropped = 不可逆丢弃帧计数(P1-1 显式读数)。 */
|
|
45
|
+
export function createTalkBucket(cap) {
|
|
46
|
+
const c = Math.floor(Number(cap));
|
|
47
|
+
const use = Number.isFinite(c) && c > 0 ? c : TALK_CAP_DEFAULT;
|
|
48
|
+
return { reasoning: [], content: [], tools: [], n: 0, capped: false, cap: use, remain: "", dropped: 0 };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 「该不该建桶 + 建多大」的唯一判据,收在这里是为了给 stream.js 让体积(20KB 硬门)。
|
|
53
|
+
* talk.log 与 agent 回路任一开启都必须建桶:只认 talkLogEnabled() 会让「talk.log 关 + full 开」
|
|
54
|
+
* 的响应正文全丢(detail.talk 为 null,capture 全程记到空气)。上限取 talkCapChars() 单一真相。
|
|
55
|
+
*/
|
|
56
|
+
export function createTalkBucketIfEnabled() {
|
|
57
|
+
return talkLogEnabled() || agentLoopEnabled() ? createTalkBucket(talkCapChars()) : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 完整单行消费(talk 桶的唯一落点,从原 captureTalkSse 循环体抽出,口径逐字不变)。glued=该行由跨 chunk 拼回。 */
|
|
61
|
+
function talkConsumeLine(talk, raw, glued = false) {
|
|
62
|
+
if (glued && raw.includes("\uFFFD")) { talk.dropped = (Number(talk.dropped) || 0) + 1; return; } // 字节级切点:解码损伤不可逆,整行弃但必须留数(根因在 stream.js chunkText 逐 chunk 无状态解码,真修另起 change)
|
|
63
|
+
const t = raw.trim();
|
|
64
|
+
if (!t.startsWith("data:")) return;
|
|
65
|
+
const d = t.slice(5).trim();
|
|
66
|
+
if (!d || d === "[DONE]") return;
|
|
67
|
+
let j = null;
|
|
68
|
+
try { j = JSON.parse(d); } catch { return; }
|
|
69
|
+
const c0 = Array.isArray(j?.choices) ? j.choices[0] : null;
|
|
70
|
+
if (!c0) return;
|
|
71
|
+
for (const src of [c0.delta, c0.message]) {
|
|
72
|
+
if (!src) continue;
|
|
73
|
+
talkPush(talk, "reasoning", src.reasoning_content ?? src.reasoning);
|
|
74
|
+
talkPush(talk, "content", src.content);
|
|
75
|
+
if (Array.isArray(src.tool_calls)) talkMergeToolCalls(talk, src.tool_calls);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 已见工具调用 index:按 detail 对象存进 WeakMap,既不污染 detail(它会被序列化进 events/模型日志,
|
|
80
|
+
// 多一个属性就是日志噪音),也不随 detail 的展开/复制丢失统计口径。
|
|
81
|
+
const tcIndexes = new WeakMap();
|
|
82
|
+
|
|
83
|
+
/** 工具调用去重计数:同一 `index` 的多个分片只算一次;分片缺 `index` 时退回 0。 */
|
|
84
|
+
function countToolCalls(detail, txt) {
|
|
85
|
+
if (!detail || typeof detail !== "object") return;
|
|
86
|
+
let seen = tcIndexes.get(detail);
|
|
87
|
+
if (!seen) { seen = new Set(); tcIndexes.set(detail, seen); }
|
|
88
|
+
const re = /"tool_calls"\s*:\s*\[\s*\{/g;
|
|
89
|
+
let m;
|
|
90
|
+
while ((m = re.exec(txt))) {
|
|
91
|
+
// 从 tool_calls 数组起点往后取第一个 index(choices[].index 在此位置之前,不会误取)
|
|
92
|
+
const after = txt.slice(m.index + m[0].length - 1);
|
|
93
|
+
const im = after.match(/"index"\s*:\s*(\d+)/);
|
|
94
|
+
seen.add(im ? Number(im[1]) : 0);
|
|
95
|
+
}
|
|
96
|
+
if (seen.size) detail.toolCalls = seen.size;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 逐 chunk 累积一轮对话的思考/正文/工具调用。talk 为空 = 未开启,直接返回。跨 chunk 行缓冲见文件头。 */
|
|
100
|
+
export function captureTalkSse(talk, txt) {
|
|
101
|
+
if (!talk) return;
|
|
102
|
+
if (talk.remain && talk.remain.length > TALK_REMAIN_MAX) { talk.remain = ""; talk.dropped = (Number(talk.dropped) || 0) + 1; } // 拼接前丢弃超限半行:内存不尖峰(review P2-5)且显式记数;本轮正常数据照常消费
|
|
103
|
+
const hadRemain = Boolean(talk.remain);
|
|
104
|
+
const buf = (hadRemain ? talk.remain : "") + String(txt ?? "");
|
|
105
|
+
talk.remain = "";
|
|
106
|
+
if (!buf) return;
|
|
107
|
+
const lines = buf.split("\n");
|
|
108
|
+
if (!buf.endsWith("\n")) talk.remain = lines.pop() ?? "";
|
|
109
|
+
for (let i = 0; i < lines.length; i++) talkConsumeLine(talk, lines[i], hadRemain && i === 0);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 流收场回收:把缓冲里缺尾换行的末行按同一单行口径入桶(截断流不静默吞末帧)。幂等;无桶/空缓冲零副作用。 */
|
|
113
|
+
export function captureTalkFlush(talk) {
|
|
114
|
+
if (!talk || !talk.remain) return;
|
|
115
|
+
const raw = talk.remain;
|
|
116
|
+
talk.remain = "";
|
|
117
|
+
talkConsumeLine(talk, raw, true);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 非流式:整块 message 直接入桶(tool_calls 参数本来就是完整的)。 */
|
|
121
|
+
export function captureTalkMessage(talk, msg) {
|
|
122
|
+
if (!talk || !msg) return;
|
|
123
|
+
talkPush(talk, "reasoning", msg.reasoning_content ?? msg.reasoning);
|
|
124
|
+
talkPush(talk, "content", msg.content);
|
|
125
|
+
if (Array.isArray(msg.tool_calls)) talkMergeToolCalls(talk, msg.tool_calls);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 透传兜底:无 chat 帧可解析(纯文本/未知形状)时把原文当正文收下,别让对话日志留空洞。 */
|
|
129
|
+
export function captureTalkFallback(talk, text) {
|
|
130
|
+
if (!talk || !text) return;
|
|
131
|
+
if (talk.reasoning.length || talk.content.length || talk.tools.length) return;
|
|
132
|
+
if (String(text).includes('"choices"')) return;
|
|
133
|
+
talkPush(talk, "content", String(text));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 判据必须跑在**完整行**上:TCP 会把一帧切两块,`"finish_reason"`/`"content"` 被切在中间时正则永不命中
|
|
137
|
+
// ⇒ sawFinishReason 恒 null(正常结束的流被 endTruncatedStream 误判成截断,向客户端写假 STREAM_TRUNCATED)、
|
|
138
|
+
// chars 归零(chatShaped 为真时正好命中空轮判据)。与 BUG-24/25 同型:那次是终止标记被切块,这次是观测层缺行缓冲。
|
|
139
|
+
const scanRemain = new WeakMap();
|
|
140
|
+
const SCAN_REMAIN_MAX = 1_000_000; // 半行上限:超限按完整行处理,宁可错判也不无界吃内存
|
|
141
|
+
|
|
142
|
+
/** 流收场回收:末尾无换行的残行按同一口径扫一次,否则末帧的 finish_reason 永远读不到。幂等。 */
|
|
143
|
+
export function flushScanRemainder(detail) {
|
|
144
|
+
if (!detail || typeof detail !== "object") return;
|
|
145
|
+
const rest = scanRemain.get(detail);
|
|
146
|
+
if (!rest) return;
|
|
147
|
+
scanRemain.delete(detail);
|
|
148
|
+
scanCompleteText(detail, rest);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 逐 SSE chunk 扫描:done/finish_reason/chat 形状/工具调用/usage/正文字符
|
|
152
|
+
export function scanSseChunk(detail, txt) {
|
|
153
|
+
if (detail.talk) { try { captureTalkSse(detail.talk, txt); } catch {} } // 纯旁路红线:捕获异常绝不外溢成转发失败(review P2-7:旧调用点裸奔在 try 外只是口头契约)
|
|
154
|
+
let buf = (scanRemain.get(detail) || "") + String(txt ?? "");
|
|
155
|
+
const rest = buf.slice(buf.lastIndexOf("\n") + 1);
|
|
156
|
+
if (rest.length > SCAN_REMAIN_MAX) { scanRemain.set(detail, ""); }
|
|
157
|
+
else { buf = buf.slice(0, buf.length - rest.length); scanRemain.set(detail, rest); }
|
|
158
|
+
if (buf) scanCompleteText(detail, buf);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function scanCompleteText(detail, txt) {
|
|
162
|
+
try {
|
|
163
|
+
if (txt.includes("[DONE]")) detail.sawDone = true;
|
|
164
|
+
const m = txt.match(/"finish_reason"\s*:\s*"([^"]+)"/);
|
|
165
|
+
if (m) detail.sawFinishReason = m[1];
|
|
166
|
+
// chat 形状证据:只有看得出是 chat 轮才配判空(非 chat SSE/JSON 透传是正式契约,不得误伤)
|
|
167
|
+
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;
|
|
168
|
+
// 工具调用计数(空数组不算):tool_calls 无正文是合法 agent 轮,
|
|
169
|
+
// 空转闸门必须豁免它,否则所有工具轮都会被误判为空轮——见 relay-pipeline 4b。
|
|
170
|
+
// 口径按 `index` 去重:流式 tool_calls 的 arguments 是**分片**到达的,每个分片都含
|
|
171
|
+
// `"tool_calls":[{"index":N,...` ⇒ 按命中次数累加会把一次调用计成 N 次(实测 4894/5999 帧命中,
|
|
172
|
+
// 虚高数千倍,且与 scanNonStreamBody 的 `.length` 口径不一致)。去重后与非流式同义。
|
|
173
|
+
countToolCalls(detail, txt);
|
|
174
|
+
// 尝试提取 usage(流式末帧):口径收口到 metrics.js,与未流式分支共用
|
|
175
|
+
if (txt.includes("\"usage\"") || txt.includes("\"prompt_tokens\"")) {
|
|
176
|
+
try {
|
|
177
|
+
const lines = txt.split("\n");
|
|
178
|
+
for (const line of lines) {
|
|
179
|
+
const t = line.trim();
|
|
180
|
+
if (!t.startsWith("data:")) continue;
|
|
181
|
+
const d = t.slice(5).trim();
|
|
182
|
+
if (d === "[DONE]" || !d) continue;
|
|
183
|
+
// 行级隔离:单行坏 JSON 不拖累同 chunk 其余行
|
|
184
|
+
try {
|
|
185
|
+
const j = JSON.parse(d);
|
|
186
|
+
if (!j || typeof j !== "object") continue;
|
|
187
|
+
const u = extractUsageFromJson(j);
|
|
188
|
+
if (u) detail.usage = u;
|
|
189
|
+
// 兜底 chars:从 choices 文本长度累加
|
|
190
|
+
const delta = j.choices?.[0]?.delta?.content || j.choices?.[0]?.message?.content || "";
|
|
191
|
+
if (delta) detail.chars += String(delta).length;
|
|
192
|
+
const rc = j.choices?.[0]?.delta?.reasoning_content || j.choices?.[0]?.delta?.reasoning
|
|
193
|
+
|| j.choices?.[0]?.message?.reasoning_content || j.choices?.[0]?.message?.reasoning || "";
|
|
194
|
+
if (rc) detail.reasoningChars = (detail.reasoningChars || 0) + String(rc).length;
|
|
195
|
+
} catch { /* 单行坏帧忽略 */ }
|
|
196
|
+
}
|
|
197
|
+
} catch {}
|
|
198
|
+
} else {
|
|
199
|
+
// 非 usage 的普通 delta 也累 chars
|
|
200
|
+
try {
|
|
201
|
+
const ms = txt.match(/"content"\s*:\s*"([^"]*)"/g);
|
|
202
|
+
if (ms) for (const mm of ms) {
|
|
203
|
+
const c = JSON.parse(`{${mm}}`);
|
|
204
|
+
if (c.content) detail.chars += String(c.content).length;
|
|
205
|
+
}
|
|
206
|
+
// 同一 chunk 里的思考增量:键名两种上游都见过(reasoning_content / reasoning)
|
|
207
|
+
const rs = txt.match(/"reasoning(?:_content)?"\s*:\s*"([^"]*)"/g);
|
|
208
|
+
if (rs) for (const mm of rs) {
|
|
209
|
+
const c = JSON.parse(`{${mm}}`);
|
|
210
|
+
const v = c.reasoning_content ?? c.reasoning;
|
|
211
|
+
if (v) detail.reasoningChars = (detail.reasoningChars || 0) + String(v).length;
|
|
212
|
+
}
|
|
213
|
+
} catch {}
|
|
214
|
+
}
|
|
215
|
+
} catch { /* ignore */ }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 非流式聚合 JSON 体:同源口径提 usage/chars/chat 形状/工具调用
|
|
219
|
+
export function scanNonStreamBody(detail, parsed) {
|
|
220
|
+
const u = extractUsageFromJson(parsed);
|
|
221
|
+
if (u) detail.usage = u;
|
|
222
|
+
if (parsed.choices?.[0]?.message?.content) detail.chars = String(parsed.choices[0].message.content).length;
|
|
223
|
+
else if (parsed.choices?.[0]?.text) detail.chars = String(parsed.choices[0].text).length;
|
|
224
|
+
// 思考内容另记(qoder 的 stream:false 出口把思考放在 message.reasoning_content)
|
|
225
|
+
const rc = parsed.choices?.[0]?.message?.reasoning_content || parsed.choices?.[0]?.message?.reasoning || "";
|
|
226
|
+
if (rc) detail.reasoningChars = (detail.reasoningChars || 0) + String(rc).length;
|
|
227
|
+
// 非流式同样只判 chat 形状:无 choices 的任意 JSON 是透传契约(chat-route 单测锁死),不得判空
|
|
228
|
+
if (Array.isArray(parsed?.choices)) detail.chatShaped = true;
|
|
229
|
+
const _tcList = parsed.choices?.[0]?.message?.tool_calls;
|
|
230
|
+
if (Array.isArray(_tcList) && _tcList.length) detail.toolCalls = _tcList.length;
|
|
231
|
+
if (detail.talk) captureTalkMessage(detail.talk, parsed.choices?.[0]?.message || (parsed.choices?.[0]?.text ? { content: parsed.choices[0].text } : null));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 上报锚点偏移:转发入口时刻 − 本次上游尝试起点(performance.now 同源)。
|
|
235
|
+
// 只服务上报时长(usage 行),四类保护闸门仍用 relay 自己的 t0,绝不引用它。
|
|
236
|
+
export function preflightMs(t0, attemptStartMs) {
|
|
237
|
+
// 锚点必须是与 t0 同源(performance.now)的有限 number;其余一律按「无锚点」降级为 0,
|
|
238
|
+
// 宁可退回修复前口径,也不能产出 NaN 或假高值
|
|
239
|
+
if (typeof attemptStartMs !== "number" || !Number.isFinite(attemptStartMs)) return 0;
|
|
240
|
+
const delta = Math.round(t0 - attemptStartMs);
|
|
241
|
+
return delta > 0 ? delta : 0;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 空轮判据(纯函数,唯一真相):看得 chat 形状 + 零正文 + 零工具调用 + 收尾不是工具轮。
|
|
246
|
+
* stream.js 用它决定「这次要不要封口」,relay-pipeline 用它决定「交不交回重试」——两处不得各写一份。
|
|
247
|
+
* chatShaped 是前置证据:非 chat SSE / 无 choices 的透传是正式契约,一律放行不判空。
|
|
248
|
+
*/
|
|
249
|
+
export function isEmptyTurnDetail(d) {
|
|
250
|
+
if (!d || d.chatShaped !== true) return false;
|
|
251
|
+
if ((Number(d.chars) || 0) > 0 || (Number(d.toolCalls) || 0) > 0) return false;
|
|
252
|
+
if (["tool_calls", "function_call"].includes(d.sawFinishReason)) return false;
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ===== 从 stream.js 迁入(体积门:stream.js 承载 relay 本体,纯判定函数一律住这里)=====
|
|
257
|
+
|
|
258
|
+
// 错误包络 chunk 判定(纯函数):data 行 JSON 含顶层 .error 对象、或只有 finish_reason=error
|
|
259
|
+
// 的空帧,且整 chunk 无任何 content/tool_calls/reasoning 输出 → 返回错误摘要(建议暂扣),
|
|
260
|
+
// 否则返回 null(透传)。解析失败一律透传(默认保安全)。
|
|
261
|
+
// 背景:网关把上游失败包成 HTTP 200 SSE;暂扣后下游流式 UI 不再展示瞬时错误
|
|
262
|
+
//(如 Vertex 503 + google fallback 400),本轮走空转重试,客户端只看到最终结果。
|
|
263
|
+
// Note: 流内错误包络捕获与 retryAfterSeconds 错误直出 — 见 .agents/notes/implemented/bug-fix/2026-09-28-retry-after-surfacing-error.md
|
|
264
|
+
export function extractRetryAfterSec(obj) {
|
|
265
|
+
if (!obj) return null;
|
|
266
|
+
if (typeof obj === "number") return obj;
|
|
267
|
+
if (typeof obj === "string") {
|
|
268
|
+
const m = obj.match(/"retryAfterSeconds"\s*:\s*(\d+)/i) || obj.match(/\bretryAfterSeconds\s*[:=]\s*(\d+)/i);
|
|
269
|
+
if (m) return Number(m[1]);
|
|
270
|
+
try {
|
|
271
|
+
const parsed = JSON.parse(obj);
|
|
272
|
+
return extractRetryAfterSec(parsed);
|
|
273
|
+
} catch {}
|
|
274
|
+
}
|
|
275
|
+
if (typeof obj === "object") {
|
|
276
|
+
if (Number.isFinite(obj.retryAfterSeconds)) return Number(obj.retryAfterSeconds);
|
|
277
|
+
if (obj.message) {
|
|
278
|
+
const r = extractRetryAfterSec(obj.message);
|
|
279
|
+
if (r != null) return r;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function holdableChunk(txt) {
|
|
286
|
+
if (typeof txt !== "string" || !txt.includes("data:")) return null;
|
|
287
|
+
let sawData = false;
|
|
288
|
+
let sawEventError = false;
|
|
289
|
+
let retrySec = null;
|
|
290
|
+
const errs = [];
|
|
291
|
+
for (const rawLine of txt.split("\n")) {
|
|
292
|
+
const t = rawLine.trim();
|
|
293
|
+
if (t.startsWith("event:") && t.slice(6).trim() === "error") {
|
|
294
|
+
sawEventError = true;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (!t.startsWith("data:")) continue;
|
|
298
|
+
const d = t.slice(5).trim();
|
|
299
|
+
if (!d || d === "[DONE]") continue;
|
|
300
|
+
let j = null;
|
|
301
|
+
try { j = JSON.parse(d); } catch { return null; }
|
|
302
|
+
if (!j || typeof j !== "object") return null;
|
|
303
|
+
sawData = true;
|
|
304
|
+
const c0 = (Array.isArray(j.choices) && j.choices[0]) || {};
|
|
305
|
+
const delta = c0.delta || {};
|
|
306
|
+
const msg = c0.message || {};
|
|
307
|
+
const out = delta.content || msg.content || delta.tool_calls || msg.tool_calls || delta.reasoning || msg.reasoning;
|
|
308
|
+
if (out && !(Array.isArray(out) && out.length === 0)) return null;
|
|
309
|
+
|
|
310
|
+
const r = extractRetryAfterSec(j);
|
|
311
|
+
if (r != null) {
|
|
312
|
+
retrySec = r;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (j.error && typeof j.error === "object") {
|
|
316
|
+
const m = j.error.message || j.error.code || "";
|
|
317
|
+
if (m) errs.push(String(m).slice(0, 300));
|
|
318
|
+
} else if (sawEventError && j.message) {
|
|
319
|
+
errs.push(String(j.message).slice(0, 300));
|
|
320
|
+
} else if (c0.finish_reason === "error") {
|
|
321
|
+
errs.push("finish_reason=error 空帧");
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (!sawData) return null;
|
|
325
|
+
if (retrySec != null) return `上游供应商触发 retryAfterSeconds: ${retrySec} ,请等候重试`;
|
|
326
|
+
if (errs.length) return errs.join(" | ");
|
|
327
|
+
if (sawEventError) return "event: error 空帧";
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* 「可撤销前缀」判据(纯函数):这个 chunk 里除空 delta 帧与 [DONE] 外不含任何信息吗?
|
|
333
|
+
* 只有答案为是才允许暂扣不写——透传是正式契约:解析失败的帧、看不懂的形状一律立即透传(与
|
|
334
|
+
* holdableChunk 同一口径:默认保安全,绝不因为想救空轮而扣掉本该原样转给客户端的字节)。
|
|
335
|
+
*/
|
|
336
|
+
export function isTrivialFrame(txt) {
|
|
337
|
+
if (typeof txt !== "string" || !txt.includes("data:")) return false;
|
|
338
|
+
let sawData = false;
|
|
339
|
+
for (const rawLine of txt.split("\n")) {
|
|
340
|
+
const t = rawLine.trim();
|
|
341
|
+
if (!t.startsWith("data:")) continue;
|
|
342
|
+
const d = t.slice(5).trim();
|
|
343
|
+
if (!d) continue;
|
|
344
|
+
if (d === "[DONE]") { sawData = true; continue; } // 结束符不含模型输出,可撤销
|
|
345
|
+
let j = null;
|
|
346
|
+
try { j = JSON.parse(d); } catch { return false; } // 解析失败 → 透传,绝不撤销
|
|
347
|
+
if (!j || typeof j !== "object" || Array.isArray(j)) return false;
|
|
348
|
+
// 白名单式可撤销:只认「choices 数组、且其中没有任何文本载荷」的 chat 帧。
|
|
349
|
+
// 其余形状(choices[].text、delta.text、顶层 usage、无 choices 的自定义帧……)一律算有信息 → 透传。
|
|
350
|
+
if (!Array.isArray(j.choices)) return false;
|
|
351
|
+
sawData = true;
|
|
352
|
+
if (j.choices.some((c) => c && typeof c === "object" && (c.text || c.message?.text))) return false;
|
|
353
|
+
for (const c of j.choices) {
|
|
354
|
+
if (!c || typeof c !== "object") return false;
|
|
355
|
+
for (const src of [c.delta, c.message]) {
|
|
356
|
+
if (!src || typeof src !== "object") continue;
|
|
357
|
+
for (const [k, v] of Object.entries(src)) {
|
|
358
|
+
if (k === "role" || k === "index" || k === "function_call_name") continue; // 无信息骨架字段
|
|
359
|
+
if (v == null || v === "") continue;
|
|
360
|
+
if (Array.isArray(v) ? v.length > 0 : true) return false; // 任何非空载荷都算产出
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return sawData;
|
|
366
|
+
}
|