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,365 +1,365 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* POST /v1/messages + /v1/messages/count_tokens — 给 Claude Code 用的 Anthropic Messages 外壳(ADR-0047)。
|
|
3
|
-
* 复用 ChatPipeline 全链路(auto/hedge/failover/tool_calls/空轮重试),只做形状翻译:
|
|
4
|
-
* 非流式:收集 chat JSON → 转 Anthropic message(上游只会流式时先把 SSE 聚合成 chat JSON);流式:逐块实时翻成 Anthropic 具名事件。
|
|
5
|
-
*
|
|
6
|
-
* 出站格式与 /v1/responses **不兼容**,故不能复用 createLiveForwarder(它写死 `data:` + `[DONE]`):
|
|
7
|
-
* Anthropic 用 `event: <type>` 具名帧、无 `[DONE]`,且需网关自发 `ping` 扛过客户端 idle watchdog。
|
|
8
|
-
*
|
|
9
|
-
* 三条收口纪律(第 1 轮评审 P0-2/P0-3 立下的,别改回去):
|
|
10
|
-
* 1. 上游一有动静(含 pipeline 的 keepalive 注释帧)就发头 + `message_start` + 武装 ping —— 首块前的静默期正是客户端 watchdog 咬人的窗口;
|
|
11
|
-
* 2. `headersSent`/`getHeader` 必须真反映已发头,否则 `helpers.js:32` 的「已 flush 走 in-band 错误帧」守卫永远判假,
|
|
12
|
-
* 错误会被当成普通文本喂进 push() 后静默消失;
|
|
13
|
-
* 3. 没等到 `finish_reason`(也没有 `[DONE]`)就收线 = 截断,发 `event: error`,**不伪装成功收场**。
|
|
14
|
-
*/
|
|
15
|
-
import { json, readBody } from "./helpers.js";
|
|
16
|
-
import { createChatPipeline } from "../chat-pipeline/index.js";
|
|
17
|
-
import { withBody, createCollector, createEmitter } from "./responses-route.js";
|
|
18
|
-
import {
|
|
19
|
-
messagesToChatBody,
|
|
20
|
-
chatJsonToAnthropic,
|
|
21
|
-
createAnthropicChunkTranslator,
|
|
22
|
-
anthropicError,
|
|
23
|
-
} from "../anthropic/translate.js";
|
|
24
|
-
import { estimateTokens } from "../anthropic/count-tokens.js";
|
|
25
|
-
import { sseToChatJson } from "../anthropic/sse-aggregate.js";
|
|
26
|
-
import {
|
|
27
|
-
detectSearchRequest,
|
|
28
|
-
runWebSearch,
|
|
29
|
-
searchConfig,
|
|
30
|
-
buildSearchMessage,
|
|
31
|
-
searchEvents,
|
|
32
|
-
} from "../anthropic/web-search.js";
|
|
33
|
-
|
|
34
|
-
const ADEBUG = process.env.MSLXDFF_ANTHROPIC_DEBUG === "1";
|
|
35
|
-
function alog(...a) {
|
|
36
|
-
if (ADEBUG) console.log("[messages]", ...a);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// thinking 块外发:默认开,`MSLXDFF_ANTHROPIC_THINKING=0` 才关。外发的 thinking 块必须在封口前交一帧占位
|
|
40
|
-
// signature_delta(translate.js 的 thinkingSignature),缺它 Claude Code 整块丢弃 — ADR-0051 外发 / ADR-0052 签名
|
|
41
|
-
function emitThinking() { return process.env.MSLXDFF_ANTHROPIC_THINKING !== "0"; }
|
|
42
|
-
|
|
43
|
-
// 空闲 ping 周期(ms):`0` 关闭;客户端长思考期靠它扛过 streaming idle timeout
|
|
44
|
-
const PING_MS = (() => {
|
|
45
|
-
const raw = Number(process.env.MSLXDFF_ANTHROPIC_PING_MS);
|
|
46
|
-
return Number.isFinite(raw) && raw >= 0 ? raw : 15000;
|
|
47
|
-
})();
|
|
48
|
-
|
|
49
|
-
function frameOf(type, data) {
|
|
50
|
-
return `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// pipeline 在已 flush 的流里用 OpenAI 形状收口(`{error:{...}}` + `[DONE]`):
|
|
54
|
-
// 检出后改发 Anthropic 的 `error` 帧,别把错误伪装成正常收场(grill-decisions Q2)
|
|
55
|
-
function detectInBandError(text) {
|
|
56
|
-
let msg = null;
|
|
57
|
-
for (const line of String(text || "").split("\n")) {
|
|
58
|
-
const t = line.trim();
|
|
59
|
-
if (!t.startsWith("data:")) continue;
|
|
60
|
-
const payload = t.slice(5).trim();
|
|
61
|
-
if (!payload || payload === "[DONE]") continue;
|
|
62
|
-
try {
|
|
63
|
-
const j = JSON.parse(payload);
|
|
64
|
-
const e = j?.error;
|
|
65
|
-
if (e) msg = String(e?.message || e).slice(0, 500);
|
|
66
|
-
} catch { /* 非 JSON 帧忽略 */ }
|
|
67
|
-
}
|
|
68
|
-
return msg;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
// pipeline 未 flush 时用 json() 直写聚合错误体(无 `data:` 前缀):认出来只取 message,
|
|
73
|
-
// 别把整坨 OpenAI JSON 当人话塞进 Anthropic error.message。
|
|
74
|
-
function jsonErrorText(text) {
|
|
75
|
-
const t = String(text || "").trim();
|
|
76
|
-
if (!t || t.startsWith(":") || t.includes("data:")) return null;
|
|
77
|
-
try {
|
|
78
|
-
const j = JSON.parse(t);
|
|
79
|
-
const e = j?.error;
|
|
80
|
-
if (!e) return null;
|
|
81
|
-
return String(e?.message || e).slice(0, 500);
|
|
82
|
-
} catch {
|
|
83
|
-
return null;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** 两种上游收场形状(in-band `data:` 帧 / 聚合 JSON 体)统一取错误文案。 */
|
|
88
|
-
function errorText(text) {
|
|
89
|
-
return detectInBandError(text) || jsonErrorText(text);
|
|
90
|
-
}
|
|
91
|
-
// 客户端要流式而上游给了聚合 JSON(muse-spark / workbuddy 聚合 / zcode 非流式聚合都是现网常态):
|
|
92
|
-
// 那体没有 `data:` 前缀,会被 push() 当噪声整段丢掉 → 正文静默消失。认出来交给 fromChatJson 兜底。
|
|
93
|
-
function tryAggregate(text) {
|
|
94
|
-
const t = String(text || "").trim();
|
|
95
|
-
if (!t || t.includes("data:") || t.startsWith(":")) return null;
|
|
96
|
-
try {
|
|
97
|
-
const j = JSON.parse(t);
|
|
98
|
-
// 只认「非空 choices」:`{choices:[]}` 之类的空包什么也带不来,若当合法聚合包会跳过截断检测 → 零正文还谎报成功
|
|
99
|
-
return j && Array.isArray(j.choices) && j.choices.length > 0 ? j : null;
|
|
100
|
-
} catch {
|
|
101
|
-
return null;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Anthropic 出站垫片:具名事件 + 无 [DONE] + 空闲自发 ping + 真 headersSent。
|
|
108
|
-
* @param realRes 真 HTTP response
|
|
109
|
-
* @param translator createAnthropicChunkTranslator 实例
|
|
110
|
-
* @param opts { pingMs?: number } 测试注入口,缺省读 env
|
|
111
|
-
*/
|
|
112
|
-
export function createAnthropicForwarder(realRes, translator, opts = {}) {
|
|
113
|
-
const pingMs = opts.pingMs ?? PING_MS;
|
|
114
|
-
let status = 200;
|
|
115
|
-
let headSent = false;
|
|
116
|
-
let ended = false;
|
|
117
|
-
let timer = null;
|
|
118
|
-
let pendingError = null;
|
|
119
|
-
let aggregate = null;
|
|
120
|
-
let rawAcc = ""; // 攒住无事件字节,供聚合回落识别
|
|
121
|
-
const sent = () => headSent || realRes.headersSent === true; // headersSent 与 getHeader 的唯一口径
|
|
122
|
-
// 上游按字节粒度喂 Buffer/Uint8Array:非流式解码会把多字节汉字切成 U+FFFD 并永久固化在行缓冲里
|
|
123
|
-
const decoder = new TextDecoder("utf-8");
|
|
124
|
-
const decode = (c, flush) => {
|
|
125
|
-
if (c == null) return flush ? String(decoder.decode()) : "";
|
|
126
|
-
if (typeof c === "string") return c;
|
|
127
|
-
const bytes = (typeof Buffer !== "undefined" && Buffer.isBuffer(c)) || c instanceof Uint8Array ? c : null;
|
|
128
|
-
if (bytes) return decoder.decode(bytes, { stream: !flush });
|
|
129
|
-
return String(c);
|
|
130
|
-
};
|
|
131
|
-
const dead = () => ended || realRes.writableEnded === true || realRes.destroyed === true;
|
|
132
|
-
const raw = (type, data) => { if (dead()) return; try { realRes.write(frameOf(type, data)); } catch { /* 下游已断 */ } };
|
|
133
|
-
const stopPing = () => { if (timer) { clearTimeout(timer); timer = null; } };
|
|
134
|
-
const armPing = () => {
|
|
135
|
-
if (!pingMs || dead()) return;
|
|
136
|
-
stopPing();
|
|
137
|
-
timer = setTimeout(() => { if (dead()) return; raw("ping", { type: "ping" }); armPing(); }, pingMs);
|
|
138
|
-
if (typeof timer.unref === "function") timer.unref();
|
|
139
|
-
};
|
|
140
|
-
const sendHead = () => {
|
|
141
|
-
if (headSent) return;
|
|
142
|
-
headSent = true;
|
|
143
|
-
try { realRes.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" }); } catch { /* 已发 */ }
|
|
144
|
-
for (const e of translator.begin()) raw(e.type, e);
|
|
145
|
-
armPing();
|
|
146
|
-
};
|
|
147
|
-
const finish = () => { if (ended) return; ended = true; stopPing(); try { realRes.end(); } catch { /* ignore */ } };
|
|
148
|
-
// 错误收场:一帧 error 后关连接,不补 message_delta/message_stop(真 Anthropic API 即此行为)
|
|
149
|
-
const closeError = (msg) => { sendHead(); raw("error", { type: "error", error: { type: "api_error", message: String(msg || "upstream error").slice(0, 500) } }); finish(); };
|
|
150
|
-
|
|
151
|
-
// 整包聚合体可能被分多次 write:攒住「没产出任何事件」的字节再判一次(有事件即清空,防陈旧拼接)
|
|
152
|
-
const RAW_ACC_CAP = 262144;
|
|
153
|
-
let rawOverflow = false;
|
|
154
|
-
const tryAccumulate = (text) => {
|
|
155
|
-
if (aggregate || !text || rawOverflow) return;
|
|
156
|
-
rawAcc += text;
|
|
157
|
-
if (rawAcc.length > RAW_ACC_CAP) {
|
|
158
|
-
// 超上限就停手并留标记:继续攒也拼不出完整 JSON,静默攒下去只会让错误文案说谎
|
|
159
|
-
rawAcc = rawAcc.slice(0, RAW_ACC_CAP);
|
|
160
|
-
rawOverflow = true;
|
|
161
|
-
}
|
|
162
|
-
const agg = tryAggregate(rawAcc);
|
|
163
|
-
if (agg) aggregate = agg;
|
|
164
|
-
};
|
|
165
|
-
const res = {
|
|
166
|
-
...createEmitter(),
|
|
167
|
-
set statusCode(v) { status = v; },
|
|
168
|
-
get statusCode() { return status; },
|
|
169
|
-
// helpers.js 的 json() 靠 headersSent + getHeader("content-type") 判「能不能回头」;
|
|
170
|
-
// 两者必须同源说真话,否则会出现「判成已 flush、却又拿不到 event-stream」的全静默窗口(错误既不 in-band 也不 JSON)。
|
|
171
|
-
get headersSent() { return sent(); },
|
|
172
|
-
// json() 的幂等早退判据;补上它才不会在我们已收场后再被当「可写」的响应对待
|
|
173
|
-
get writableEnded() { return ended || realRes.writableEnded === true; },
|
|
174
|
-
setHeader() { /* Anthropic SSE 的头由 sendHead 统一发,逐键 set 无意义 */ },
|
|
175
|
-
getHeader(name) {
|
|
176
|
-
if (String(name).toLowerCase() === "content-type" && sent()) return "text/event-stream";
|
|
177
|
-
return undefined;
|
|
178
|
-
},
|
|
179
|
-
write(c) {
|
|
180
|
-
if (ended) { alog("late-write-ignored", JSON.stringify({ id: translator.id, bytes: String(c?.length ?? 0) })); return true; }
|
|
181
|
-
const text = decode(c);
|
|
182
|
-
const err = errorText(text);
|
|
183
|
-
if (err) { pendingError = err; return true; } // 留给 end() 统一收口,不在这里半截发
|
|
184
|
-
// 上游一有动静(连 pipeline 的 keepalive 注释帧都算)就落头 + message_start + 武装 ping:
|
|
185
|
-
// 首块前的静默期正是客户端 idle watchdog 咬人的窗口。没收到任何字节时错误仍走干净 JSON(见 end())。
|
|
186
|
-
sendHead();
|
|
187
|
-
const evs = translator.push(text);
|
|
188
|
-
for (const e of evs) raw(e.type, e);
|
|
189
|
-
if (evs.length) rawAcc = ""; else tryAccumulate(text);
|
|
190
|
-
armPing();
|
|
191
|
-
return true;
|
|
192
|
-
},
|
|
193
|
-
end(c) {
|
|
194
|
-
if (ended) return;
|
|
195
|
-
const tail = decode(c, true);
|
|
196
|
-
const err = pendingError || errorText(tail);
|
|
197
|
-
if (status >= 400 && !headSent) {
|
|
198
|
-
ended = true;
|
|
199
|
-
stopPing();
|
|
200
|
-
json(realRes, status, anthropicError("api_error", err || String(tail).slice(0, 500) || "upstream error"));
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
|
-
if (err) { closeError(err); return; }
|
|
204
|
-
// 客户端要的是流:先落头 + message_start + 武装 ping,再处理残余
|
|
205
|
-
sendHead();
|
|
206
|
-
if (tail) {
|
|
207
|
-
const evs = translator.push(tail);
|
|
208
|
-
for (const e of evs) raw(e.type, e);
|
|
209
|
-
if (evs.length) rawAcc = ""; else tryAccumulate(tail);
|
|
210
|
-
}
|
|
211
|
-
// 末帧常常没有收尾换行:补一个 `\n` 把残行逼出来,否则合法正文与 finish_reason 会一起烂在缓冲里(被误判成截断)
|
|
212
|
-
for (const e of translator.push("\n")) raw(e.type, e);
|
|
213
|
-
// 上游整包聚合(无 SSE 帧):喂同一套状态机兜底,别让正文静默消失
|
|
214
|
-
if (aggregate && !translator.stats().blocks) { for (const e of translator.fromChatJson(aggregate)) raw(e.type, e); }
|
|
215
|
-
const fin = translator.getFinal();
|
|
216
|
-
if (!fin.finish && !aggregate) {
|
|
217
|
-
alog("truncated", JSON.stringify({ id: translator.id, ...translator.stats() }));
|
|
218
|
-
closeError(rawOverflow
|
|
219
|
-
? "upstream sent an oversized non-SSE body that could not be aggregated (no finish_reason)"
|
|
220
|
-
: "upstream stream ended before the model finished its turn (no finish_reason)");
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
|
-
for (const e of translator.end({ finish: fin.finish, usage: fin.usage })) raw(e.type, e);
|
|
224
|
-
finish();
|
|
225
|
-
},
|
|
226
|
-
};
|
|
227
|
-
// 客户端提前断开:pipeline 会感知 close,但 ping 定时器归本垫片管,必须自己停
|
|
228
|
-
try { realRes.on?.("close", () => { if (!ended) { ended = true; stopPing(); } }); } catch { /* ignore */ }
|
|
229
|
-
return res;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* 代跑结果回给客户端:非流式整包 JSON,流式由同一份块生成的具名事件(无 `[DONE]`)。
|
|
234
|
-
* 搜索在调用前就跑完了,这里一次写完即可,不需要 createAnthropicForwarder 的 ping 垫片。
|
|
235
|
-
*/
|
|
236
|
-
function respondSearch(res, { message, stream, provider }, isDead) {
|
|
237
|
-
if (isDead && isDead()) return undefined; // 搜索期间客户端断连:别再写任何字节
|
|
238
|
-
if (!stream) {
|
|
239
|
-
try { res.setHeader?.("x-mslxdff-web-search", provider); } catch { /* 已 flush */ }
|
|
240
|
-
try { return json(res, 200, message); } catch { return undefined; /* 下游已断 */ }
|
|
241
|
-
}
|
|
242
|
-
try {
|
|
243
|
-
res.writeHead(200, {
|
|
244
|
-
"Content-Type": "text/event-stream",
|
|
245
|
-
"Cache-Control": "no-cache",
|
|
246
|
-
Connection: "keep-alive",
|
|
247
|
-
"x-mslxdff-web-search": provider,
|
|
248
|
-
});
|
|
249
|
-
for (const e of searchEvents(message)) res.write(frameOf(e.type, e));
|
|
250
|
-
res.end();
|
|
251
|
-
} catch { /* 下游已断 */ }
|
|
252
|
-
return undefined;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
export async function messagesHandler(ctx) {
|
|
256
|
-
const { req, res } = ctx;
|
|
257
|
-
const t0 = Date.now();
|
|
258
|
-
let body;
|
|
259
|
-
try {
|
|
260
|
-
body = await readBody(req);
|
|
261
|
-
} catch {
|
|
262
|
-
return json(res, 400, anthropicError("invalid_request_error", "Invalid JSON body"));
|
|
263
|
-
}
|
|
264
|
-
// —— 网关代跑 web_search(ADR-0049):旁路请求**一律由网关作答**——命中判据即不打模型。
|
|
265
|
-
// 搜到就回结果块;关了/全挂/空查询就回一条明确的 400(把无工具的旁路丢给上游,只会让模型凭空
|
|
266
|
-
// 编 URL 并让客户端按「0 次搜索」空转重试,那才是改前的老毛病)。主循环请求不受影响。
|
|
267
|
-
// 整段包 try:搜索期最长 15s,其间客户端可能已断连(`isDead`),任何意外都不得逃逸成 5xx。
|
|
268
|
-
const det = detectSearchRequest(body);
|
|
269
|
-
if (det.hit) {
|
|
270
|
-
const isDead = () => res.writableEnded === true || res.destroyed === true || res.headersSent === true;
|
|
271
|
-
const wsCfg = searchConfig();
|
|
272
|
-
let r;
|
|
273
|
-
if (!wsCfg.enabled) r = { ok: false, error: "disabled by MSLXDFF_WEB_SEARCH=off" };
|
|
274
|
-
else {
|
|
275
|
-
try {
|
|
276
|
-
r = det.query ? await runWebSearch(det.query, wsCfg) : { ok: false, error: "empty query" };
|
|
277
|
-
} catch (e) {
|
|
278
|
-
r = { ok: false, error: String(e?.message || e) };
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
if (r.ok) {
|
|
282
|
-
const readout = { provider: r.provider, ms: r.ms, results: r.results.length, queryChars: r.query.length, chain: r.chain || [] };
|
|
283
|
-
alog("web-search", JSON.stringify(readout));
|
|
284
|
-
try { ctx.logs?.appendEvent?.({ type: "web-search", ...readout }); } catch { /* 观测产物不参与判决 */ }
|
|
285
|
-
return respondSearch(res, {
|
|
286
|
-
message: buildSearchMessage({ model: String(body.model || ""), query: r.query, results: r.results }),
|
|
287
|
-
stream: Boolean(body.stream),
|
|
288
|
-
provider: r.provider,
|
|
289
|
-
}, isDead);
|
|
290
|
-
}
|
|
291
|
-
const why = String(r.error || "unknown").slice(0, 200);
|
|
292
|
-
alog("web-search-unavailable", why);
|
|
293
|
-
try { ctx.logs?.appendEvent?.({ type: "web-search-unavailable", queryChars: (det.query || "").length, error: why, chain: r.chain || [] }); } catch { /* 同上 */ }
|
|
294
|
-
if (isDead()) return undefined;
|
|
295
|
-
try { return json(res, 400, anthropicError("invalid_request_error", `web search failed at the gateway: ${why}`)); } catch { return undefined; }
|
|
296
|
-
}
|
|
297
|
-
let chatBody;
|
|
298
|
-
try {
|
|
299
|
-
chatBody = messagesToChatBody(body);
|
|
300
|
-
} catch (e) {
|
|
301
|
-
alog("translate-req-400", String(e?.message || e));
|
|
302
|
-
return json(res, 400, anthropicError("invalid_request_error", String(e?.message || e)));
|
|
303
|
-
}
|
|
304
|
-
alog("req", JSON.stringify({
|
|
305
|
-
model: chatBody.model, stream: chatBody.stream,
|
|
306
|
-
msgs: chatBody.messages.map((m) => `${m.role}:${(typeof m.content === "string" ? m.content : "[parts]").length}${m.tool_calls ? `+${m.tool_calls.length}tc` : ""}`),
|
|
307
|
-
tools: Array.isArray(chatBody.tools) ? chatBody.tools.length : 0,
|
|
308
|
-
}));
|
|
309
|
-
const pipeline = createChatPipeline(ctx);
|
|
310
|
-
const fakeReq = withBody(req, chatBody);
|
|
311
|
-
// ADR-0055:请求侧 token 估算 thunk(路由层也 memoize,与决策 4「每请求至多算一次」对齐;
|
|
312
|
-
// translator 内部还有一层 memoize,双保险)。
|
|
313
|
-
let estCache;
|
|
314
|
-
const estInput = () => (estCache ??= estimateTokens(body).input_tokens);
|
|
315
|
-
const forwardClose = (shim) => { try { req.on?.("close", () => shim.emit("close")); } catch { /* ignore */ } };
|
|
316
|
-
try {
|
|
317
|
-
if (chatBody.stream) {
|
|
318
|
-
const translator = createAnthropicChunkTranslator(chatBody.model, { thinking: emitThinking(), estInput });
|
|
319
|
-
const live = createAnthropicForwarder(res, translator);
|
|
320
|
-
forwardClose(live);
|
|
321
|
-
await pipeline.execute({ req: fakeReq, res: live });
|
|
322
|
-
alog("done-stream", JSON.stringify({ ms: Date.now() - t0, id: translator.id, ...translator.stats(), ...translator.getFinal() }));
|
|
323
|
-
} else {
|
|
324
|
-
const cap = createCollector();
|
|
325
|
-
forwardClose(cap.res);
|
|
326
|
-
await pipeline.execute({ req: fakeReq, res: cap.res });
|
|
327
|
-
const { status, text } = cap.get();
|
|
328
|
-
if (status >= 400) return json(res, status, anthropicError("api_error", errorText(text) || String(text).slice(0, 500)));
|
|
329
|
-
let chatJson = null;
|
|
330
|
-
try { chatJson = JSON.parse(text); } catch { /* 非 JSON */ }
|
|
331
|
-
// 上游只会流式(workbuddy 写死 stream:true):非流式收到的 text 是 SSE → 聚成 chat JSON 再走同一翻译出口;
|
|
332
|
-
// 空流/错误帧聚合不出 JSON 时仍落回下方原 502 路径。
|
|
333
|
-
if (!chatJson) chatJson = sseToChatJson(text, chatBody.model);
|
|
334
|
-
if (!chatJson || chatJson.error || chatJson.object === "error") {
|
|
335
|
-
return json(res, status >= 400 ? status : 502, anthropicError("api_error", errorText(text) || String(text).slice(0, 500)));
|
|
336
|
-
}
|
|
337
|
-
// 上游偶发非 chat 形状(无 choices):包成单 text 块,绝不 500(照 responses-route.js 先例)
|
|
338
|
-
if (!Array.isArray(chatJson.choices)) {
|
|
339
|
-
const r = chatJsonToAnthropic({ choices: [{ message: { content: String(text).slice(0, 8000) } }] }, chatBody.model, { estInput });
|
|
340
|
-
alog("done-nonchat", JSON.stringify({ ms: Date.now() - t0, bytes: text.length }));
|
|
341
|
-
return json(res, 200, r);
|
|
342
|
-
}
|
|
343
|
-
const out = chatJsonToAnthropic(chatJson, chatBody.model, { estInput });
|
|
344
|
-
alog("done-json", JSON.stringify({ ms: Date.now() - t0, status, blocks: out.content.length, stop: out.stop_reason, usage: out.usage }));
|
|
345
|
-
return json(res, 200, out);
|
|
346
|
-
}
|
|
347
|
-
} catch (err) {
|
|
348
|
-
alog("execute-throw", String(err?.message || err).slice(0, 300));
|
|
349
|
-
if (!res.headersSent) return json(res, 502, anthropicError("api_error", String(err?.message || err)));
|
|
350
|
-
try { res.end(); } catch { /* ignore */ }
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
export async function countTokensHandler(ctx) {
|
|
355
|
-
const { req, res } = ctx;
|
|
356
|
-
let body;
|
|
357
|
-
try {
|
|
358
|
-
body = await readBody(req);
|
|
359
|
-
} catch {
|
|
360
|
-
return json(res, 400, anthropicError("invalid_request_error", "Invalid JSON body"));
|
|
361
|
-
}
|
|
362
|
-
const out = estimateTokens(body);
|
|
363
|
-
alog("count-tokens", JSON.stringify({ model: body?.model, input_tokens: out.input_tokens }));
|
|
364
|
-
return json(res, 200, out);
|
|
365
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* POST /v1/messages + /v1/messages/count_tokens — 给 Claude Code 用的 Anthropic Messages 外壳(ADR-0047)。
|
|
3
|
+
* 复用 ChatPipeline 全链路(auto/hedge/failover/tool_calls/空轮重试),只做形状翻译:
|
|
4
|
+
* 非流式:收集 chat JSON → 转 Anthropic message(上游只会流式时先把 SSE 聚合成 chat JSON);流式:逐块实时翻成 Anthropic 具名事件。
|
|
5
|
+
*
|
|
6
|
+
* 出站格式与 /v1/responses **不兼容**,故不能复用 createLiveForwarder(它写死 `data:` + `[DONE]`):
|
|
7
|
+
* Anthropic 用 `event: <type>` 具名帧、无 `[DONE]`,且需网关自发 `ping` 扛过客户端 idle watchdog。
|
|
8
|
+
*
|
|
9
|
+
* 三条收口纪律(第 1 轮评审 P0-2/P0-3 立下的,别改回去):
|
|
10
|
+
* 1. 上游一有动静(含 pipeline 的 keepalive 注释帧)就发头 + `message_start` + 武装 ping —— 首块前的静默期正是客户端 watchdog 咬人的窗口;
|
|
11
|
+
* 2. `headersSent`/`getHeader` 必须真反映已发头,否则 `helpers.js:32` 的「已 flush 走 in-band 错误帧」守卫永远判假,
|
|
12
|
+
* 错误会被当成普通文本喂进 push() 后静默消失;
|
|
13
|
+
* 3. 没等到 `finish_reason`(也没有 `[DONE]`)就收线 = 截断,发 `event: error`,**不伪装成功收场**。
|
|
14
|
+
*/
|
|
15
|
+
import { json, readBody } from "./helpers.js";
|
|
16
|
+
import { createChatPipeline } from "../chat-pipeline/index.js";
|
|
17
|
+
import { withBody, createCollector, createEmitter } from "./responses-route.js";
|
|
18
|
+
import {
|
|
19
|
+
messagesToChatBody,
|
|
20
|
+
chatJsonToAnthropic,
|
|
21
|
+
createAnthropicChunkTranslator,
|
|
22
|
+
anthropicError,
|
|
23
|
+
} from "../anthropic/translate.js";
|
|
24
|
+
import { estimateTokens } from "../anthropic/count-tokens.js";
|
|
25
|
+
import { sseToChatJson } from "../anthropic/sse-aggregate.js";
|
|
26
|
+
import {
|
|
27
|
+
detectSearchRequest,
|
|
28
|
+
runWebSearch,
|
|
29
|
+
searchConfig,
|
|
30
|
+
buildSearchMessage,
|
|
31
|
+
searchEvents,
|
|
32
|
+
} from "../anthropic/web-search.js";
|
|
33
|
+
|
|
34
|
+
const ADEBUG = process.env.MSLXDFF_ANTHROPIC_DEBUG === "1";
|
|
35
|
+
function alog(...a) {
|
|
36
|
+
if (ADEBUG) console.log("[messages]", ...a);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// thinking 块外发:默认开,`MSLXDFF_ANTHROPIC_THINKING=0` 才关。外发的 thinking 块必须在封口前交一帧占位
|
|
40
|
+
// signature_delta(translate.js 的 thinkingSignature),缺它 Claude Code 整块丢弃 — ADR-0051 外发 / ADR-0052 签名
|
|
41
|
+
function emitThinking() { return process.env.MSLXDFF_ANTHROPIC_THINKING !== "0"; }
|
|
42
|
+
|
|
43
|
+
// 空闲 ping 周期(ms):`0` 关闭;客户端长思考期靠它扛过 streaming idle timeout
|
|
44
|
+
const PING_MS = (() => {
|
|
45
|
+
const raw = Number(process.env.MSLXDFF_ANTHROPIC_PING_MS);
|
|
46
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : 15000;
|
|
47
|
+
})();
|
|
48
|
+
|
|
49
|
+
function frameOf(type, data) {
|
|
50
|
+
return `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// pipeline 在已 flush 的流里用 OpenAI 形状收口(`{error:{...}}` + `[DONE]`):
|
|
54
|
+
// 检出后改发 Anthropic 的 `error` 帧,别把错误伪装成正常收场(grill-decisions Q2)
|
|
55
|
+
function detectInBandError(text) {
|
|
56
|
+
let msg = null;
|
|
57
|
+
for (const line of String(text || "").split("\n")) {
|
|
58
|
+
const t = line.trim();
|
|
59
|
+
if (!t.startsWith("data:")) continue;
|
|
60
|
+
const payload = t.slice(5).trim();
|
|
61
|
+
if (!payload || payload === "[DONE]") continue;
|
|
62
|
+
try {
|
|
63
|
+
const j = JSON.parse(payload);
|
|
64
|
+
const e = j?.error;
|
|
65
|
+
if (e) msg = String(e?.message || e).slice(0, 500);
|
|
66
|
+
} catch { /* 非 JSON 帧忽略 */ }
|
|
67
|
+
}
|
|
68
|
+
return msg;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
// pipeline 未 flush 时用 json() 直写聚合错误体(无 `data:` 前缀):认出来只取 message,
|
|
73
|
+
// 别把整坨 OpenAI JSON 当人话塞进 Anthropic error.message。
|
|
74
|
+
function jsonErrorText(text) {
|
|
75
|
+
const t = String(text || "").trim();
|
|
76
|
+
if (!t || t.startsWith(":") || t.includes("data:")) return null;
|
|
77
|
+
try {
|
|
78
|
+
const j = JSON.parse(t);
|
|
79
|
+
const e = j?.error;
|
|
80
|
+
if (!e) return null;
|
|
81
|
+
return String(e?.message || e).slice(0, 500);
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 两种上游收场形状(in-band `data:` 帧 / 聚合 JSON 体)统一取错误文案。 */
|
|
88
|
+
function errorText(text) {
|
|
89
|
+
return detectInBandError(text) || jsonErrorText(text);
|
|
90
|
+
}
|
|
91
|
+
// 客户端要流式而上游给了聚合 JSON(muse-spark / workbuddy 聚合 / zcode 非流式聚合都是现网常态):
|
|
92
|
+
// 那体没有 `data:` 前缀,会被 push() 当噪声整段丢掉 → 正文静默消失。认出来交给 fromChatJson 兜底。
|
|
93
|
+
function tryAggregate(text) {
|
|
94
|
+
const t = String(text || "").trim();
|
|
95
|
+
if (!t || t.includes("data:") || t.startsWith(":")) return null;
|
|
96
|
+
try {
|
|
97
|
+
const j = JSON.parse(t);
|
|
98
|
+
// 只认「非空 choices」:`{choices:[]}` 之类的空包什么也带不来,若当合法聚合包会跳过截断检测 → 零正文还谎报成功
|
|
99
|
+
return j && Array.isArray(j.choices) && j.choices.length > 0 ? j : null;
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Anthropic 出站垫片:具名事件 + 无 [DONE] + 空闲自发 ping + 真 headersSent。
|
|
108
|
+
* @param realRes 真 HTTP response
|
|
109
|
+
* @param translator createAnthropicChunkTranslator 实例
|
|
110
|
+
* @param opts { pingMs?: number } 测试注入口,缺省读 env
|
|
111
|
+
*/
|
|
112
|
+
export function createAnthropicForwarder(realRes, translator, opts = {}) {
|
|
113
|
+
const pingMs = opts.pingMs ?? PING_MS;
|
|
114
|
+
let status = 200;
|
|
115
|
+
let headSent = false;
|
|
116
|
+
let ended = false;
|
|
117
|
+
let timer = null;
|
|
118
|
+
let pendingError = null;
|
|
119
|
+
let aggregate = null;
|
|
120
|
+
let rawAcc = ""; // 攒住无事件字节,供聚合回落识别
|
|
121
|
+
const sent = () => headSent || realRes.headersSent === true; // headersSent 与 getHeader 的唯一口径
|
|
122
|
+
// 上游按字节粒度喂 Buffer/Uint8Array:非流式解码会把多字节汉字切成 U+FFFD 并永久固化在行缓冲里
|
|
123
|
+
const decoder = new TextDecoder("utf-8");
|
|
124
|
+
const decode = (c, flush) => {
|
|
125
|
+
if (c == null) return flush ? String(decoder.decode()) : "";
|
|
126
|
+
if (typeof c === "string") return c;
|
|
127
|
+
const bytes = (typeof Buffer !== "undefined" && Buffer.isBuffer(c)) || c instanceof Uint8Array ? c : null;
|
|
128
|
+
if (bytes) return decoder.decode(bytes, { stream: !flush });
|
|
129
|
+
return String(c);
|
|
130
|
+
};
|
|
131
|
+
const dead = () => ended || realRes.writableEnded === true || realRes.destroyed === true;
|
|
132
|
+
const raw = (type, data) => { if (dead()) return; try { realRes.write(frameOf(type, data)); } catch { /* 下游已断 */ } };
|
|
133
|
+
const stopPing = () => { if (timer) { clearTimeout(timer); timer = null; } };
|
|
134
|
+
const armPing = () => {
|
|
135
|
+
if (!pingMs || dead()) return;
|
|
136
|
+
stopPing();
|
|
137
|
+
timer = setTimeout(() => { if (dead()) return; raw("ping", { type: "ping" }); armPing(); }, pingMs);
|
|
138
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
139
|
+
};
|
|
140
|
+
const sendHead = () => {
|
|
141
|
+
if (headSent) return;
|
|
142
|
+
headSent = true;
|
|
143
|
+
try { realRes.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" }); } catch { /* 已发 */ }
|
|
144
|
+
for (const e of translator.begin()) raw(e.type, e);
|
|
145
|
+
armPing();
|
|
146
|
+
};
|
|
147
|
+
const finish = () => { if (ended) return; ended = true; stopPing(); try { realRes.end(); } catch { /* ignore */ } };
|
|
148
|
+
// 错误收场:一帧 error 后关连接,不补 message_delta/message_stop(真 Anthropic API 即此行为)
|
|
149
|
+
const closeError = (msg) => { sendHead(); raw("error", { type: "error", error: { type: "api_error", message: String(msg || "upstream error").slice(0, 500) } }); finish(); };
|
|
150
|
+
|
|
151
|
+
// 整包聚合体可能被分多次 write:攒住「没产出任何事件」的字节再判一次(有事件即清空,防陈旧拼接)
|
|
152
|
+
const RAW_ACC_CAP = 262144;
|
|
153
|
+
let rawOverflow = false;
|
|
154
|
+
const tryAccumulate = (text) => {
|
|
155
|
+
if (aggregate || !text || rawOverflow) return;
|
|
156
|
+
rawAcc += text;
|
|
157
|
+
if (rawAcc.length > RAW_ACC_CAP) {
|
|
158
|
+
// 超上限就停手并留标记:继续攒也拼不出完整 JSON,静默攒下去只会让错误文案说谎
|
|
159
|
+
rawAcc = rawAcc.slice(0, RAW_ACC_CAP);
|
|
160
|
+
rawOverflow = true;
|
|
161
|
+
}
|
|
162
|
+
const agg = tryAggregate(rawAcc);
|
|
163
|
+
if (agg) aggregate = agg;
|
|
164
|
+
};
|
|
165
|
+
const res = {
|
|
166
|
+
...createEmitter(),
|
|
167
|
+
set statusCode(v) { status = v; },
|
|
168
|
+
get statusCode() { return status; },
|
|
169
|
+
// helpers.js 的 json() 靠 headersSent + getHeader("content-type") 判「能不能回头」;
|
|
170
|
+
// 两者必须同源说真话,否则会出现「判成已 flush、却又拿不到 event-stream」的全静默窗口(错误既不 in-band 也不 JSON)。
|
|
171
|
+
get headersSent() { return sent(); },
|
|
172
|
+
// json() 的幂等早退判据;补上它才不会在我们已收场后再被当「可写」的响应对待
|
|
173
|
+
get writableEnded() { return ended || realRes.writableEnded === true; },
|
|
174
|
+
setHeader() { /* Anthropic SSE 的头由 sendHead 统一发,逐键 set 无意义 */ },
|
|
175
|
+
getHeader(name) {
|
|
176
|
+
if (String(name).toLowerCase() === "content-type" && sent()) return "text/event-stream";
|
|
177
|
+
return undefined;
|
|
178
|
+
},
|
|
179
|
+
write(c) {
|
|
180
|
+
if (ended) { alog("late-write-ignored", JSON.stringify({ id: translator.id, bytes: String(c?.length ?? 0) })); return true; }
|
|
181
|
+
const text = decode(c);
|
|
182
|
+
const err = errorText(text);
|
|
183
|
+
if (err) { pendingError = err; return true; } // 留给 end() 统一收口,不在这里半截发
|
|
184
|
+
// 上游一有动静(连 pipeline 的 keepalive 注释帧都算)就落头 + message_start + 武装 ping:
|
|
185
|
+
// 首块前的静默期正是客户端 idle watchdog 咬人的窗口。没收到任何字节时错误仍走干净 JSON(见 end())。
|
|
186
|
+
sendHead();
|
|
187
|
+
const evs = translator.push(text);
|
|
188
|
+
for (const e of evs) raw(e.type, e);
|
|
189
|
+
if (evs.length) rawAcc = ""; else tryAccumulate(text);
|
|
190
|
+
armPing();
|
|
191
|
+
return true;
|
|
192
|
+
},
|
|
193
|
+
end(c) {
|
|
194
|
+
if (ended) return;
|
|
195
|
+
const tail = decode(c, true);
|
|
196
|
+
const err = pendingError || errorText(tail);
|
|
197
|
+
if (status >= 400 && !headSent) {
|
|
198
|
+
ended = true;
|
|
199
|
+
stopPing();
|
|
200
|
+
json(realRes, status, anthropicError("api_error", err || String(tail).slice(0, 500) || "upstream error"));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (err) { closeError(err); return; }
|
|
204
|
+
// 客户端要的是流:先落头 + message_start + 武装 ping,再处理残余
|
|
205
|
+
sendHead();
|
|
206
|
+
if (tail) {
|
|
207
|
+
const evs = translator.push(tail);
|
|
208
|
+
for (const e of evs) raw(e.type, e);
|
|
209
|
+
if (evs.length) rawAcc = ""; else tryAccumulate(tail);
|
|
210
|
+
}
|
|
211
|
+
// 末帧常常没有收尾换行:补一个 `\n` 把残行逼出来,否则合法正文与 finish_reason 会一起烂在缓冲里(被误判成截断)
|
|
212
|
+
for (const e of translator.push("\n")) raw(e.type, e);
|
|
213
|
+
// 上游整包聚合(无 SSE 帧):喂同一套状态机兜底,别让正文静默消失
|
|
214
|
+
if (aggregate && !translator.stats().blocks) { for (const e of translator.fromChatJson(aggregate)) raw(e.type, e); }
|
|
215
|
+
const fin = translator.getFinal();
|
|
216
|
+
if (!fin.finish && !aggregate) {
|
|
217
|
+
alog("truncated", JSON.stringify({ id: translator.id, ...translator.stats() }));
|
|
218
|
+
closeError(rawOverflow
|
|
219
|
+
? "upstream sent an oversized non-SSE body that could not be aggregated (no finish_reason)"
|
|
220
|
+
: "upstream stream ended before the model finished its turn (no finish_reason)");
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
for (const e of translator.end({ finish: fin.finish, usage: fin.usage })) raw(e.type, e);
|
|
224
|
+
finish();
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
// 客户端提前断开:pipeline 会感知 close,但 ping 定时器归本垫片管,必须自己停
|
|
228
|
+
try { realRes.on?.("close", () => { if (!ended) { ended = true; stopPing(); } }); } catch { /* ignore */ }
|
|
229
|
+
return res;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* 代跑结果回给客户端:非流式整包 JSON,流式由同一份块生成的具名事件(无 `[DONE]`)。
|
|
234
|
+
* 搜索在调用前就跑完了,这里一次写完即可,不需要 createAnthropicForwarder 的 ping 垫片。
|
|
235
|
+
*/
|
|
236
|
+
function respondSearch(res, { message, stream, provider }, isDead) {
|
|
237
|
+
if (isDead && isDead()) return undefined; // 搜索期间客户端断连:别再写任何字节
|
|
238
|
+
if (!stream) {
|
|
239
|
+
try { res.setHeader?.("x-mslxdff-web-search", provider); } catch { /* 已 flush */ }
|
|
240
|
+
try { return json(res, 200, message); } catch { return undefined; /* 下游已断 */ }
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
res.writeHead(200, {
|
|
244
|
+
"Content-Type": "text/event-stream",
|
|
245
|
+
"Cache-Control": "no-cache",
|
|
246
|
+
Connection: "keep-alive",
|
|
247
|
+
"x-mslxdff-web-search": provider,
|
|
248
|
+
});
|
|
249
|
+
for (const e of searchEvents(message)) res.write(frameOf(e.type, e));
|
|
250
|
+
res.end();
|
|
251
|
+
} catch { /* 下游已断 */ }
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export async function messagesHandler(ctx) {
|
|
256
|
+
const { req, res } = ctx;
|
|
257
|
+
const t0 = Date.now();
|
|
258
|
+
let body;
|
|
259
|
+
try {
|
|
260
|
+
body = await readBody(req);
|
|
261
|
+
} catch {
|
|
262
|
+
return json(res, 400, anthropicError("invalid_request_error", "Invalid JSON body"));
|
|
263
|
+
}
|
|
264
|
+
// —— 网关代跑 web_search(ADR-0049):旁路请求**一律由网关作答**——命中判据即不打模型。
|
|
265
|
+
// 搜到就回结果块;关了/全挂/空查询就回一条明确的 400(把无工具的旁路丢给上游,只会让模型凭空
|
|
266
|
+
// 编 URL 并让客户端按「0 次搜索」空转重试,那才是改前的老毛病)。主循环请求不受影响。
|
|
267
|
+
// 整段包 try:搜索期最长 15s,其间客户端可能已断连(`isDead`),任何意外都不得逃逸成 5xx。
|
|
268
|
+
const det = detectSearchRequest(body);
|
|
269
|
+
if (det.hit) {
|
|
270
|
+
const isDead = () => res.writableEnded === true || res.destroyed === true || res.headersSent === true;
|
|
271
|
+
const wsCfg = searchConfig();
|
|
272
|
+
let r;
|
|
273
|
+
if (!wsCfg.enabled) r = { ok: false, error: "disabled by MSLXDFF_WEB_SEARCH=off" };
|
|
274
|
+
else {
|
|
275
|
+
try {
|
|
276
|
+
r = det.query ? await runWebSearch(det.query, wsCfg) : { ok: false, error: "empty query" };
|
|
277
|
+
} catch (e) {
|
|
278
|
+
r = { ok: false, error: String(e?.message || e) };
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (r.ok) {
|
|
282
|
+
const readout = { provider: r.provider, ms: r.ms, results: r.results.length, queryChars: r.query.length, chain: r.chain || [] };
|
|
283
|
+
alog("web-search", JSON.stringify(readout));
|
|
284
|
+
try { ctx.logs?.appendEvent?.({ type: "web-search", ...readout }); } catch { /* 观测产物不参与判决 */ }
|
|
285
|
+
return respondSearch(res, {
|
|
286
|
+
message: buildSearchMessage({ model: String(body.model || ""), query: r.query, results: r.results }),
|
|
287
|
+
stream: Boolean(body.stream),
|
|
288
|
+
provider: r.provider,
|
|
289
|
+
}, isDead);
|
|
290
|
+
}
|
|
291
|
+
const why = String(r.error || "unknown").slice(0, 200);
|
|
292
|
+
alog("web-search-unavailable", why);
|
|
293
|
+
try { ctx.logs?.appendEvent?.({ type: "web-search-unavailable", queryChars: (det.query || "").length, error: why, chain: r.chain || [] }); } catch { /* 同上 */ }
|
|
294
|
+
if (isDead()) return undefined;
|
|
295
|
+
try { return json(res, 400, anthropicError("invalid_request_error", `web search failed at the gateway: ${why}`)); } catch { return undefined; }
|
|
296
|
+
}
|
|
297
|
+
let chatBody;
|
|
298
|
+
try {
|
|
299
|
+
chatBody = messagesToChatBody(body);
|
|
300
|
+
} catch (e) {
|
|
301
|
+
alog("translate-req-400", String(e?.message || e));
|
|
302
|
+
return json(res, 400, anthropicError("invalid_request_error", String(e?.message || e)));
|
|
303
|
+
}
|
|
304
|
+
alog("req", JSON.stringify({
|
|
305
|
+
model: chatBody.model, stream: chatBody.stream,
|
|
306
|
+
msgs: chatBody.messages.map((m) => `${m.role}:${(typeof m.content === "string" ? m.content : "[parts]").length}${m.tool_calls ? `+${m.tool_calls.length}tc` : ""}`),
|
|
307
|
+
tools: Array.isArray(chatBody.tools) ? chatBody.tools.length : 0,
|
|
308
|
+
}));
|
|
309
|
+
const pipeline = createChatPipeline(ctx);
|
|
310
|
+
const fakeReq = withBody(req, chatBody);
|
|
311
|
+
// ADR-0055:请求侧 token 估算 thunk(路由层也 memoize,与决策 4「每请求至多算一次」对齐;
|
|
312
|
+
// translator 内部还有一层 memoize,双保险)。
|
|
313
|
+
let estCache;
|
|
314
|
+
const estInput = () => (estCache ??= estimateTokens(body).input_tokens);
|
|
315
|
+
const forwardClose = (shim) => { try { req.on?.("close", () => shim.emit("close")); } catch { /* ignore */ } };
|
|
316
|
+
try {
|
|
317
|
+
if (chatBody.stream) {
|
|
318
|
+
const translator = createAnthropicChunkTranslator(chatBody.model, { thinking: emitThinking(), estInput });
|
|
319
|
+
const live = createAnthropicForwarder(res, translator);
|
|
320
|
+
forwardClose(live);
|
|
321
|
+
await pipeline.execute({ req: fakeReq, res: live });
|
|
322
|
+
alog("done-stream", JSON.stringify({ ms: Date.now() - t0, id: translator.id, ...translator.stats(), ...translator.getFinal() }));
|
|
323
|
+
} else {
|
|
324
|
+
const cap = createCollector();
|
|
325
|
+
forwardClose(cap.res);
|
|
326
|
+
await pipeline.execute({ req: fakeReq, res: cap.res });
|
|
327
|
+
const { status, text } = cap.get();
|
|
328
|
+
if (status >= 400) return json(res, status, anthropicError("api_error", errorText(text) || String(text).slice(0, 500)));
|
|
329
|
+
let chatJson = null;
|
|
330
|
+
try { chatJson = JSON.parse(text); } catch { /* 非 JSON */ }
|
|
331
|
+
// 上游只会流式(workbuddy 写死 stream:true):非流式收到的 text 是 SSE → 聚成 chat JSON 再走同一翻译出口;
|
|
332
|
+
// 空流/错误帧聚合不出 JSON 时仍落回下方原 502 路径。
|
|
333
|
+
if (!chatJson) chatJson = sseToChatJson(text, chatBody.model);
|
|
334
|
+
if (!chatJson || chatJson.error || chatJson.object === "error") {
|
|
335
|
+
return json(res, status >= 400 ? status : 502, anthropicError("api_error", errorText(text) || String(text).slice(0, 500)));
|
|
336
|
+
}
|
|
337
|
+
// 上游偶发非 chat 形状(无 choices):包成单 text 块,绝不 500(照 responses-route.js 先例)
|
|
338
|
+
if (!Array.isArray(chatJson.choices)) {
|
|
339
|
+
const r = chatJsonToAnthropic({ choices: [{ message: { content: String(text).slice(0, 8000) } }] }, chatBody.model, { estInput });
|
|
340
|
+
alog("done-nonchat", JSON.stringify({ ms: Date.now() - t0, bytes: text.length }));
|
|
341
|
+
return json(res, 200, r);
|
|
342
|
+
}
|
|
343
|
+
const out = chatJsonToAnthropic(chatJson, chatBody.model, { estInput });
|
|
344
|
+
alog("done-json", JSON.stringify({ ms: Date.now() - t0, status, blocks: out.content.length, stop: out.stop_reason, usage: out.usage }));
|
|
345
|
+
return json(res, 200, out);
|
|
346
|
+
}
|
|
347
|
+
} catch (err) {
|
|
348
|
+
alog("execute-throw", String(err?.message || err).slice(0, 300));
|
|
349
|
+
if (!res.headersSent) return json(res, 502, anthropicError("api_error", String(err?.message || err)));
|
|
350
|
+
try { res.end(); } catch { /* ignore */ }
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export async function countTokensHandler(ctx) {
|
|
355
|
+
const { req, res } = ctx;
|
|
356
|
+
let body;
|
|
357
|
+
try {
|
|
358
|
+
body = await readBody(req);
|
|
359
|
+
} catch {
|
|
360
|
+
return json(res, 400, anthropicError("invalid_request_error", "Invalid JSON body"));
|
|
361
|
+
}
|
|
362
|
+
const out = estimateTokens(body);
|
|
363
|
+
alog("count-tokens", JSON.stringify({ model: body?.model, input_tokens: out.input_tokens }));
|
|
364
|
+
return json(res, 200, out);
|
|
365
|
+
}
|