mslxdff 0.1.233 → 0.1.235

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.
Files changed (106) hide show
  1. package/bin/mslxdff.js +3 -3
  2. package/docs/cli_help_mini.md +149 -0
  3. package/package.json +1 -1
  4. package/src/auto.js +264 -264
  5. package/src/bench/cline-bench.js +42 -42
  6. package/src/bench/probe.js +70 -70
  7. package/src/bench/report.js +162 -162
  8. package/src/bench/runner.js +77 -77
  9. package/src/bench/via-probe.js +124 -124
  10. package/src/bench/via-routes.js +87 -87
  11. package/src/bench/workbuddy-bench.js +54 -54
  12. package/src/chat/engine.js +160 -160
  13. package/src/chat/gateway.js +163 -163
  14. package/src/chat/orchestrator.js +234 -234
  15. package/src/chat/prompt.js +70 -70
  16. package/src/chat/repl.js +88 -88
  17. package/src/chat/terminal.js +135 -135
  18. package/src/chat/tools.js +306 -306
  19. package/src/chat-pipeline/index.js +155 -155
  20. package/src/chat-pipeline/policy.js +84 -84
  21. package/src/chat-pipeline/serial-trial.js +16 -3
  22. package/src/chat-pipeline/upstream-5xx-retry.js +72 -0
  23. package/src/cli/commands/group.js +266 -266
  24. package/src/cli/commands/model/picks.js +50 -50
  25. package/src/cli/commands/provider/bench-via.js +247 -247
  26. package/src/cli/commands/provider/bench.js +141 -141
  27. package/src/cli/commands/provider/index.js +139 -139
  28. package/src/cli/commands/provider/models.js +149 -149
  29. package/src/cli/commands/provider/qwenwork-login.js +119 -119
  30. package/src/cli/commands/provider/raccoon-quota.js +156 -156
  31. package/src/cli/commands/sync.js +310 -310
  32. package/src/cli/status.js +279 -279
  33. package/src/daemon.js +246 -246
  34. package/src/model-capabilities/enrich.js +86 -86
  35. package/src/model-capabilities/index.js +183 -183
  36. package/src/model-capabilities/parse.js +70 -70
  37. package/src/models.js +293 -293
  38. package/src/peers.js +345 -345
  39. package/src/providers/AGENTS.md +49 -0
  40. package/src/providers/agentrouter.js +238 -238
  41. package/src/providers/base.js +164 -164
  42. package/src/providers/cline/auth.js +228 -228
  43. package/src/providers/cline/chat.js +307 -307
  44. package/src/providers/cline.js +2 -2
  45. package/src/providers/codearts/chat.js +207 -207
  46. package/src/providers/dispatcher.js +147 -147
  47. package/src/providers/globalqwenwork/cosy.js +123 -123
  48. package/src/providers/globalqwenwork/crypto.js +218 -218
  49. package/src/providers/globalqwenwork/http.js +20 -20
  50. package/src/providers/globalqwenwork/rsa.js +54 -54
  51. package/src/providers/globalqwenwork/sse.js +268 -268
  52. package/src/providers/globalqwenwork/stream.js +130 -130
  53. package/src/providers/globalqwenwork/upstream.js +122 -122
  54. package/src/providers/keyring.js +60 -60
  55. package/src/providers/qoder/chat.js +292 -292
  56. package/src/providers/qoder/index.js +311 -311
  57. package/src/providers/qoder/sse.js +233 -233
  58. package/src/providers/qwenwork/account-store.js +133 -133
  59. package/src/providers/qwenwork/constants.js +67 -67
  60. package/src/providers/qwenwork/cosy.js +120 -120
  61. package/src/providers/qwenwork/crypto.js +218 -218
  62. package/src/providers/qwenwork/http.js +20 -20
  63. package/src/providers/qwenwork/index.js +337 -337
  64. package/src/providers/qwenwork/payload.js +144 -144
  65. package/src/providers/qwenwork/rsa.js +54 -54
  66. package/src/providers/qwenwork/sse.js +268 -268
  67. package/src/providers/qwenwork/stream.js +130 -130
  68. package/src/providers/qwenwork/upstream.js +120 -120
  69. package/src/providers/qwenwork.js +1 -1
  70. package/src/providers/raccoon/const.js +219 -219
  71. package/src/providers/raccoon/credits.js +285 -285
  72. package/src/providers/raccoon/index.js +36 -4
  73. package/src/providers/raccoon/sticky.js +46 -0
  74. package/src/providers/registry.js +89 -89
  75. package/src/providers/workbuddy/chat.js +298 -298
  76. package/src/providers/workbuddy/reshape.js +152 -152
  77. package/src/providers/workbuddy.js +2 -2
  78. package/src/reasoning.js +32 -32
  79. package/src/routes/AGENTS.md +37 -0
  80. package/src/routes/chat/broadband-handler.js +80 -80
  81. package/src/routes/chat/gateway.js +46 -46
  82. package/src/routes/chat/peer-handler.js +14 -4
  83. package/src/routes/chat/relay-pipeline.js +282 -282
  84. package/src/routes/chat/via-route-handler.js +156 -156
  85. package/src/routes/hedge.js +260 -260
  86. package/src/routes/models-route.js +167 -167
  87. package/src/routes/peer-serial.js +5 -2
  88. package/src/routes/peers.js +373 -373
  89. package/src/routes/stream.js +364 -364
  90. package/src/runtime/bootstrap.js +75 -75
  91. package/src/runtime/keep-warm.js +135 -135
  92. package/src/runtime/provider-gate.js +75 -75
  93. package/src/runtime/providers-setup.js +187 -187
  94. package/src/runtime/raccoon-checkin.js +175 -175
  95. package/src/server.js +85 -85
  96. package/src/state/schemas/allowlist.js +92 -92
  97. package/src/sync-opencode.js +280 -280
  98. package/src/transport/index.js +244 -244
  99. package/src/transport/pool.js +56 -56
  100. package/src/transport/retry.js +24 -24
  101. package/src/transport/sse.js +93 -93
  102. package/src/upstream-probe/display.js +52 -52
  103. package/src/upstream-probe/probe.js +49 -49
  104. package/src/upstream-probe/rotate.js +110 -110
  105. package/src/upstream-probe/start.js +45 -45
  106. package/src/upstream.js +355 -355
@@ -1,298 +1,298 @@
1
- import { joinUrl, envInt } from "../base.js";
2
- import { isAuthError, isInsufficientStatus, isRateLimitedStatus, parseRateLimitResetMs, isAccountRestricted } from "./auth.js";
3
- import { appendRotationLog as defaultAppend } from "./rotation-log.js";
4
- import { createTransport } from "../../transport/index.js";
5
- import { dispatcherFetch } from "../../upstream-engine/sdk/attempt.js";
6
- import { reshapeWorkbuddySse } from "./reshape.js";
7
- import { attemptOnceSdk } from "./sdk-chat.js";
8
- import { sanitizeToolSequence } from "./sanitize-tools.js";
9
- import { rewriteWorkbuddyPayload } from "./payload.js";
10
- import { resolveEngineMode } from "../../upstream-engine/mode.js";
11
- import { buildWorkbuddyHeaders } from "./client-headers.js";
12
-
13
- // 出站指纹单一真值:官方桌面端口径见 client-headers.js(含每请求随机 conversation/trace id)。
14
- // 真机 chat 的 Accept 是 application/json(SSE 由 body.stream 协商、非 Accept),故不传 accept 用默认值。
15
- function buildAuthHeaders(key, auth) {
16
- return buildWorkbuddyHeaders(key, auth);
17
- }
18
-
19
- function withUidHeader(res, uid) {
20
- try { res.headers.set("x-mslxdff-workbuddy-uid", uid); return res; }
21
- catch {
22
- try {
23
- const h = new Headers(res.headers);
24
- h.set("x-mslxdff-workbuddy-uid", uid);
25
- const c = new Response(res.body, { status: res.status, statusText: res.statusText, headers: h });
26
- c._t = res._t;
27
- return c;
28
- } catch { return res; }
29
- }
30
- }
31
-
32
- // 400 判型需读 body 文本;读过的 Response body 不可再读(下游转发报 "Body is unusable"),
33
- // 用文本重建一个等价响应(去掉 content-length 防长度不符)。
34
- function rewrapBody(res, txt) {
35
- try { console.error(`[workbuddy] upstream ${res.status} body: ${String(txt).slice(0, 300)}`); } catch {}
36
- try {
37
- const h = new Headers(res.headers);
38
- h.delete("content-length");
39
- const r = new Response(txt, { status: res.status, statusText: res.statusText, headers: h });
40
- try { r._t = res._t; } catch {}
41
- return r;
42
- } catch { return res; }
43
- }
44
-
45
- function nowMs(clock) { return typeof performance !== "undefined" && performance.now ? performance.now() : clock(); }
46
-
47
- function errRes(status, msg, reason, uid, t0, clock) {
48
- const r = new Response(JSON.stringify({ error: msg }), { status, headers: { "Content-Type": "application/json", "x-mslxdff-workbuddy-reason": reason, "x-mslxdff-workbuddy-uid": uid } });
49
- r._t = { attempts: [], waitMs: 0, totalMs: Math.round(nowMs(clock) - t0) };
50
- return r;
51
- }
52
-
53
- function failErr(msg, t0, clock, extra) {
54
- const e = new Error(msg);
55
- e._t = { attempts: [], waitMs: 0, totalMs: Math.round(nowMs(clock) - t0), ...(extra || {}) };
56
- return e;
57
- }
58
-
59
- export function createChatService({
60
- id = "workbuddy",
61
- baseUrl,
62
- chatPath,
63
- keys,
64
- authList,
65
- ring,
66
- fetchImpl,
67
- dispatcher,
68
- connectTimeoutMs = 30_000,
69
- balanceCache,
70
- authService,
71
- logger,
72
- clock = Date.now,
73
- cooldownMs = 30_000,
74
- } = {}) {
75
- // 封号 11140 冷却时长(env MSLXDFF_WORKBUDDY_RESTRICTED_COOLDOWN_MS,缺省 15min):
76
- // 腾讯"异常行为"限制可长可短,30s 档太短会反复白烧;15min 内人工申诉/自动解封后再回来。
77
- const restrictedCooldownMs = envInt("MSLXDFF_WORKBUDDY_RESTRICTED_COOLDOWN_MS", 900_000);
78
- // 最近一次「账号封禁 11140」记账 { at, uids, message }——让冷却期内到达的请求如实回 403
79
- // (旧表现:keyring 全冷却 → 笼统 "all API keys are in cooldown" 冒 502,用户/客户端看不出是封号)。
80
- let lastRestricted = null;
81
- const getBalance = balanceCache?.getCachedBalance || (() => null);
82
- const setBalance = balanceCache?.setCachedBalance || (() => {});
83
- const doLog = (opts) => {
84
- if (logger && typeof logger.append === "function") { try { logger.append(opts); } catch {} return; }
85
- if (logger && typeof logger.log === "function") { try { logger.log(opts); } catch {} return; }
86
- try { defaultAppend(opts); } catch {}
87
- };
88
- const transport = createTransport({ fetchImpl, dispatcher, keepAlive: !!dispatcher, timeoutMs: connectTimeoutMs, retry: {} });
89
- // SDK 通道与 legacy 同享 keep-alive 连接池(dispatcher → fetch 注入);同时该 fetch 被 attempt 层
90
- // 包装用于错误路径捕获请求体(上游 4xx 时打印 tool 序列断裂诊断,见 sdk/attempt.js)。
91
- const sdkFetch = dispatcher ? dispatcherFetch(dispatcher) : fetchImpl;
92
-
93
- function authForKey(key) {
94
- const idx = keys.indexOf(key);
95
- if (idx >= 0 && authList[idx]) return authList[idx];
96
- if (authList.length) return authList[0];
97
- return { uid: "", domain: "www.codebuddy.cn", enterpriseId: "", refreshToken: "" };
98
- }
99
-
100
- // SDK 通道(缺省启用):缺省底层走 @ai-sdk/openai-compatible,`legacy`/关闭词
101
- // (局部 MSLXDFF_WORKBUDDY_SDK,未设则继承全局 MSLXDFF_UPSTREAM_ENGINE)回退原生 transport;
102
- // 不可用(未安装)自动回退并告警一次。上层轮换/刷新/reshape 全链复用。
103
- // Note: 为什么默认 SDK、翻译层代价与回退语义 — 见 .agents/notes/implemented/feature/2026-09-12-workbuddy-sdk-channel.md
104
- // 上游非标字段(thinking / reasoning_effort / reasoning_content / tool_choice 字符串)不依赖 SDK 白名单:
105
- // payload.js 前置语义改写 + AI SDK providerOptions 透传(非白名单 key 原样 spread 进 body,2.0.41 实测)。
106
- // 序列化/流式/错误映射仍全部交 AI SDK;原生 transport 仅作 SDK 不可用时的回退。v0.1.120 实测见 .scratch/probe-sdk-body.mjs。
107
- const engineMode = resolveEngineMode(process.env, "MSLXDFF_WORKBUDDY_SDK", "sdk");
108
- let sdkFallbackLogged = false;
109
- async function fetchOnce(url, body, key, auth) {
110
- const payload = rewriteWorkbuddyPayload(body);
111
- if (engineMode === "sdk") {
112
- try {
113
- return await attemptOnceSdk({ url, body: payload, key, auth, buildHeaders: buildAuthHeaders, ...(sdkFetch ? { fetchImpl: sdkFetch } : {}) });
114
- } catch (e) {
115
- if (!e || !e._sdkLoadFailed) throw e;
116
- if (!sdkFallbackLogged) {
117
- sdkFallbackLogged = true;
118
- try { console.error(`[workbuddy] ${e.message} — 回退原生通道`); } catch {}
119
- }
120
- }
121
- }
122
- return transport.request({ url, headers: buildAuthHeaders(key, auth), body: { ...payload, stream: true }, stream: true });
123
- }
124
-
125
- async function withRefresh(url, body, key, auth) {
126
- let res;
127
- try { res = await fetchOnce(url, body, key, auth); }
128
- catch (e) { throw e; }
129
- if (res.status < 400) return reshapeWorkbuddySse(res);
130
- let txt = "";
131
- try { txt = await res.text(); } catch {}
132
- // 账号封禁 11140:判账号本体,refresh 无效(2026-10-21 实测刷出新 token 照样 11140),
133
- // 原样上抛让 runChat 走「长冷却 + 换号」,绝不进 refresh 连锁白烧上游。
134
- if (isAccountRestricted(res.status, txt)) {
135
- throw failErr(`workbuddy account restricted (11140) for ${auth?.uid || ""}: ${txt.slice(0, 160)}`, _t0(), clock, { restricted: true, status: res.status });
136
- }
137
- if (!isAuthError(res.status, txt)) return rewrapBody(res, txt);
138
- const newKey = await authService?.refreshTokenFor?.(key, auth);
139
- if (!newKey) throw failErr(`workbuddy refresh failed for ${auth?.uid || ""}: ${txt.slice(0, 120)}`, _t0(), clock);
140
- const auth2 = authForKey(newKey);
141
- let res2;
142
- try { res2 = await fetchOnce(url, body, newKey, auth2); }
143
- catch (e2) { throw e2; }
144
- let stillTxt = "";
145
- if (res2.status >= 400) { try { stillTxt = await res2.text(); } catch {} }
146
- // 刷新后仍被封:同属账号级限制,标记 restricted 供上层长冷却。
147
- if (isAccountRestricted(res2.status, stillTxt)) {
148
- throw failErr(`workbuddy account restricted (11140) for ${auth?.uid || ""}: ${stillTxt.slice(0, 160)}`, _t0(), clock, { restricted: true, status: res2.status });
149
- }
150
- if (stillTxt) res2 = rewrapBody(res2, stillTxt);
151
- const stillAuth = isAuthError(res2.status, stillTxt);
152
- const uid2 = auth2.uid || auth.uid;
153
- res2 = withUidHeader(res2, uid2);
154
- res2._t = res2._t || { attempts: [], waitMs: 0, totalMs: Math.round(nowMs(clock) - _t0()) };
155
- if (stillAuth || res2.status === 429 || res2.status >= 500) try { ring.onError(newKey); } catch {}
156
- doLog({ uid: uid2, model: _modelForLog(), totalMs: Math.round(nowMs(clock) - _t0()), balanceHit: false, error: stillAuth ? `still auth ${res2.status}` : undefined });
157
- if (stillAuth) throw failErr(`workbuddy auth still failing after refresh for ${uid2}: ${stillTxt.slice(0, 120)}`, _t0(), clock);
158
- return reshapeWorkbuddySse(res2);
159
- }
160
-
161
- let _t0Val = 0;
162
- let _modelVal = "";
163
- function _t0() { return _t0Val; }
164
- function _modelForLog() { return _modelVal; }
165
-
166
- async function runChat(body, activeRing, opts = {}) {
167
- const url = joinUrl(baseUrl, chatPath);
168
- const t0 = nowMs(clock);
169
- const clean = sanitizeToolSequence(body?.messages);
170
- if (clean.droppedCalls || clean.droppedResults || clean.movedResults || clean.injectedHead) {
171
- try { console.error(`[workbuddy] tool-sequence sanitized: dropped ${clean.droppedCalls} call(s), ${clean.droppedResults} result(s), moved ${clean.movedResults} result(s), head+${clean.injectedHead}, model=${body?.model || ""}`); } catch {}
172
- body = { ...body, messages: clean.messages };
173
- }
174
- _t0Val = t0;
175
- _modelVal = body?.model || "";
176
- const preferredUid = opts?.workbuddyUid ? String(opts.workbuddyUid).trim() : "";
177
-
178
- if (preferredUid) {
179
- const idx = authList.findIndex((a) => a.uid === preferredUid || String(a.uid).startsWith(preferredUid));
180
- if (idx < 0) return errRes(403, `workbuddy uid not found: ${preferredUid}`, "uid-not-found", preferredUid, t0, clock);
181
- const key = keys[idx];
182
- const auth = authList[idx];
183
- authService?.maybeProactiveRefresh?.(auth, key);
184
- const cached = getBalance(auth.uid);
185
- if (cached && Number(cached.total) === 0) return errRes(403, `workbuddy uid in cooldown (balance 0): ${auth.uid}`, "uid-cooling", auth.uid, t0, clock);
186
- try {
187
- let res = await withRefresh(url, body, key, auth);
188
- res = withUidHeader(res, auth.uid);
189
- res._t = res._t || { attempts: [], waitMs: 0, totalMs: Math.round(nowMs(clock) - t0) };
190
- if (res.status === 401 || res.status === 403 || res.status === 429 || res.status >= 500) try { activeRing.onError(key); } catch {}
191
- doLog({ uid: auth.uid, model: _modelVal, totalMs: Math.round(nowMs(clock) - t0), balanceHit: false });
192
- return res;
193
- } catch (e) {
194
- e._t = e._t || { attempts: [], waitMs: 0, totalMs: Math.round(nowMs(clock) - t0) };
195
- // pin 模式撞封号:长冷却 + 把 403 原文透回(refresh 无效,别冒 502 让客户端以为网关坏了)。
196
- if (e?._t?.restricted || e?.restricted) {
197
- try { activeRing.onError(key, restrictedCooldownMs); } catch {}
198
- doLog({ uid: auth.uid, model: _modelVal, totalMs: Math.round(nowMs(clock) - t0), balanceHit: false, error: String(e.message).slice(0, 160) });
199
- return errRes(403, String(e.message), "account-restricted", auth.uid, t0, clock);
200
- }
201
- if (!(e instanceof Error) || !String(e.message).includes("refresh failed")) try { activeRing.onError(key); } catch {}
202
- throw e;
203
- }
204
- }
205
-
206
- const tried = new Set();
207
- const maxTries = Math.max(1, keys.length);
208
- let lastErr = null;
209
- for (let triedCount = 0; triedCount < maxTries; triedCount++) {
210
- const key = activeRing.next();
211
- if (!key) {
212
- if (triedCount === 0) {
213
- // 封号冷却窗口内的重复请求:如实回 403(账号问题),别抛笼统 cooldown 冒成 502。
214
- if (lastRestricted && clock() - lastRestricted.at < restrictedCooldownMs) {
215
- return errRes(403, `workbuddy: all account(s) restricted (11140): ${String(lastRestricted.message).slice(0, 160)}`, "account-restricted", "", t0, clock);
216
- }
217
- throw failErr(`${id}: all API keys are in cooldown (last error < ${cooldownMs}ms ago) — provider temporarily unavailable`, t0, clock, { cooldownMs });
218
- }
219
- break;
220
- }
221
- const auth = authForKey(key);
222
- const uid = auth?.uid || "";
223
- if (tried.has(uid || key)) continue;
224
- tried.add(uid || key);
225
- const cached = uid ? getBalance(uid) : null;
226
- if (cached && Number(cached.total) === 0) {
227
- try { activeRing.onError(key); } catch {}
228
- setBalance(uid, { ...cached, total: 0 });
229
- doLog({ uid, model: _modelVal, totalMs: Math.round(nowMs(clock) - t0), balanceHit: true });
230
- if (keys.length === 1 || activeRing.available() === 0) return errRes(403, `workbuddy uid in cooldown (balance 0): ${uid}`, "uid-cooling", uid, t0, clock);
231
- continue;
232
- }
233
- if (!key && activeRing.size === 0) throw failErr(`${id}: missing MSLXDFF_WORKBUDDY_KEY (chat requires a real key) — run node workbuddy-token-auto.js`, t0, clock);
234
- authService?.maybeProactiveRefresh?.(auth, key);
235
- let res;
236
- try { res = await withRefresh(url, body, key, auth); }
237
- catch (e) {
238
- e._t = e._t || { attempts: [], waitMs: 0, totalMs: Math.round(nowMs(clock) - t0) };
239
- // 封号 11140:长冷却本号换下一个;冷却档走 restrictedCooldownMs(缺省 15min),别用 30s 短档反复白烧。
240
- // restricted 标记挂在 failErr 的 _t 上;SDK 通道对 4xx 抛的 AI_APICallError 带 statusCode/responseBody,一并识别。
241
- if (e?._t?.restricted || e?.restricted || isAccountRestricted(e?.statusCode, e?.responseBody)) {
242
- lastRestricted = { at: clock(), uid, message: String(e.responseBody || e.message || "").slice(0, 200) };
243
- try { activeRing.onError(key, restrictedCooldownMs); } catch {}
244
- } else try { activeRing.onError(key); } catch {}
245
- lastErr = e;
246
- continue;
247
- }
248
- if (res.status === 402 || res.status === 403 || res.status === 429) {
249
- let txt = "";
250
- try { txt = await res.text(); } catch {}
251
- if (isInsufficientStatus(res.status, txt, cached)) {
252
- try { activeRing.onError(key); } catch {}
253
- if (uid) setBalance(uid, { total: 0, dailyPacks: 0, activeCount: 0, nextExpire: null, fetchedAt: clock() });
254
- doLog({ uid, model: _modelVal, totalMs: Math.round(nowMs(clock) - t0), balanceHit: true, error: txt.slice(0, 120) });
255
- lastErr = failErr(`workbuddy insufficient for ${uid}: ${txt.slice(0, 120)}`, t0, clock);
256
- continue;
257
- }
258
- // 频率限制(腾讯 code 6004):这个号撞了限流,但**额度还在**(total 可能还有几千点)。
259
- // 必须换下一个号顶,且绝不能 setBalance(total:0) —— 那会把好号永久跳过,
260
- // 是「429 原样吐回客户端、从不换号」在 2026-10-17 实锤的那条老路。
261
- // Note: 6004 的判据与冷却分档 — 见 .agents/notes/implemented/bug-fix/2026-10-18-workbuddy-6004-rate-limit-rotate.md
262
- if (isRateLimitedStatus(res.status, txt)) {
263
- const resetMs = parseRateLimitResetMs(txt, clock);
264
- // 冷却时长取「上游给的重置时刻」与固定下限的较大者;解析不出就只按固定档,
265
- // 且**封顶 10 分钟**:6004 的重置时间可能远在数小时后,照抄会把号冻到下班。
266
- const coolMs = Math.min(Math.max(resetMs || 0, cooldownMs), 10 * 60_000);
267
- try { activeRing.onError(key, coolMs); } catch {}
268
- doLog({ uid, model: _modelVal, totalMs: Math.round(nowMs(clock) - t0), balanceHit: false, error: `rate-limited(${coolMs}ms) ${txt.slice(0, 120)}` });
269
- lastErr = failErr(`workbuddy rate limited for ${uid}: ${txt.slice(0, 120)}`, t0, clock);
270
- if (activeRing.available() > 0) continue;
271
- res = rewrapBody(res, txt);
272
- } else {
273
- res = rewrapBody(res, txt);
274
- }
275
- }
276
- if (res.status === 401 || res.status === 403 || res.status === 429 || res.status >= 500) try { activeRing.onError(key); } catch {}
277
- res = withUidHeader(res, uid);
278
- res._t = res._t || { attempts: [], waitMs: 0, totalMs: Math.round(nowMs(clock) - t0) };
279
- doLog({ uid, model: _modelVal, totalMs: Math.round(nowMs(clock) - t0), balanceHit: false });
280
- return res;
281
- }
282
- if (lastErr) {
283
- // 全号皆被封 11140:上游报文带可申诉的 displayMsg/displayTips,透回原文比模糊包装更利于用户自查;
284
- // 状态码用上游的 403(旧链是 502,客户端侧像网关坏了,实为账号问题)。
285
- if (lastErr._t?.restricted || lastErr.restricted) {
286
- lastRestricted = { at: clock(), uid: "", message: String(lastErr.message || "") };
287
- const uids = [...tried].filter((x) => x && x.includes("-")).join(",");
288
- const reason = `workbuddy all account(s) restricted (11140): ${uids || "n/a"} — ${String(lastErr.message).slice(0, 200)}`;
289
- const r = errRes(403, reason, "account-restricted", uids, t0, clock);
290
- return r;
291
- }
292
- throw lastErr;
293
- }
294
- throw failErr(`${id}: all workbuddy accounts exhausted or unavailable`, t0, clock);
295
- }
296
-
297
- return { runChat, authForKey, buildAuthHeaders, withUidHeader, attemptOnce: fetchOnce };
298
- }
1
+ import { joinUrl, envInt } from "../base.js";
2
+ import { isAuthError, isInsufficientStatus, isRateLimitedStatus, parseRateLimitResetMs, isAccountRestricted } from "./auth.js";
3
+ import { appendRotationLog as defaultAppend } from "./rotation-log.js";
4
+ import { createTransport } from "../../transport/index.js";
5
+ import { dispatcherFetch } from "../../upstream-engine/sdk/attempt.js";
6
+ import { reshapeWorkbuddySse } from "./reshape.js";
7
+ import { attemptOnceSdk } from "./sdk-chat.js";
8
+ import { sanitizeToolSequence } from "./sanitize-tools.js";
9
+ import { rewriteWorkbuddyPayload } from "./payload.js";
10
+ import { resolveEngineMode } from "../../upstream-engine/mode.js";
11
+ import { buildWorkbuddyHeaders } from "./client-headers.js";
12
+
13
+ // 出站指纹单一真值:官方桌面端口径见 client-headers.js(含每请求随机 conversation/trace id)。
14
+ // 真机 chat 的 Accept 是 application/json(SSE 由 body.stream 协商、非 Accept),故不传 accept 用默认值。
15
+ function buildAuthHeaders(key, auth) {
16
+ return buildWorkbuddyHeaders(key, auth);
17
+ }
18
+
19
+ function withUidHeader(res, uid) {
20
+ try { res.headers.set("x-mslxdff-workbuddy-uid", uid); return res; }
21
+ catch {
22
+ try {
23
+ const h = new Headers(res.headers);
24
+ h.set("x-mslxdff-workbuddy-uid", uid);
25
+ const c = new Response(res.body, { status: res.status, statusText: res.statusText, headers: h });
26
+ c._t = res._t;
27
+ return c;
28
+ } catch { return res; }
29
+ }
30
+ }
31
+
32
+ // 400 判型需读 body 文本;读过的 Response body 不可再读(下游转发报 "Body is unusable"),
33
+ // 用文本重建一个等价响应(去掉 content-length 防长度不符)。
34
+ function rewrapBody(res, txt) {
35
+ try { console.error(`[workbuddy] upstream ${res.status} body: ${String(txt).slice(0, 300)}`); } catch {}
36
+ try {
37
+ const h = new Headers(res.headers);
38
+ h.delete("content-length");
39
+ const r = new Response(txt, { status: res.status, statusText: res.statusText, headers: h });
40
+ try { r._t = res._t; } catch {}
41
+ return r;
42
+ } catch { return res; }
43
+ }
44
+
45
+ function nowMs(clock) { return typeof performance !== "undefined" && performance.now ? performance.now() : clock(); }
46
+
47
+ function errRes(status, msg, reason, uid, t0, clock) {
48
+ const r = new Response(JSON.stringify({ error: msg }), { status, headers: { "Content-Type": "application/json", "x-mslxdff-workbuddy-reason": reason, "x-mslxdff-workbuddy-uid": uid } });
49
+ r._t = { attempts: [], waitMs: 0, totalMs: Math.round(nowMs(clock) - t0) };
50
+ return r;
51
+ }
52
+
53
+ function failErr(msg, t0, clock, extra) {
54
+ const e = new Error(msg);
55
+ e._t = { attempts: [], waitMs: 0, totalMs: Math.round(nowMs(clock) - t0), ...(extra || {}) };
56
+ return e;
57
+ }
58
+
59
+ export function createChatService({
60
+ id = "workbuddy",
61
+ baseUrl,
62
+ chatPath,
63
+ keys,
64
+ authList,
65
+ ring,
66
+ fetchImpl,
67
+ dispatcher,
68
+ connectTimeoutMs = 30_000,
69
+ balanceCache,
70
+ authService,
71
+ logger,
72
+ clock = Date.now,
73
+ cooldownMs = 30_000,
74
+ } = {}) {
75
+ // 封号 11140 冷却时长(env MSLXDFF_WORKBUDDY_RESTRICTED_COOLDOWN_MS,缺省 15min):
76
+ // 腾讯"异常行为"限制可长可短,30s 档太短会反复白烧;15min 内人工申诉/自动解封后再回来。
77
+ const restrictedCooldownMs = envInt("MSLXDFF_WORKBUDDY_RESTRICTED_COOLDOWN_MS", 900_000);
78
+ // 最近一次「账号封禁 11140」记账 { at, uids, message }——让冷却期内到达的请求如实回 403
79
+ // (旧表现:keyring 全冷却 → 笼统 "all API keys are in cooldown" 冒 502,用户/客户端看不出是封号)。
80
+ let lastRestricted = null;
81
+ const getBalance = balanceCache?.getCachedBalance || (() => null);
82
+ const setBalance = balanceCache?.setCachedBalance || (() => {});
83
+ const doLog = (opts) => {
84
+ if (logger && typeof logger.append === "function") { try { logger.append(opts); } catch {} return; }
85
+ if (logger && typeof logger.log === "function") { try { logger.log(opts); } catch {} return; }
86
+ try { defaultAppend(opts); } catch {}
87
+ };
88
+ const transport = createTransport({ fetchImpl, dispatcher, keepAlive: !!dispatcher, timeoutMs: connectTimeoutMs, retry: {} });
89
+ // SDK 通道与 legacy 同享 keep-alive 连接池(dispatcher → fetch 注入);同时该 fetch 被 attempt 层
90
+ // 包装用于错误路径捕获请求体(上游 4xx 时打印 tool 序列断裂诊断,见 sdk/attempt.js)。
91
+ const sdkFetch = dispatcher ? dispatcherFetch(dispatcher) : fetchImpl;
92
+
93
+ function authForKey(key) {
94
+ const idx = keys.indexOf(key);
95
+ if (idx >= 0 && authList[idx]) return authList[idx];
96
+ if (authList.length) return authList[0];
97
+ return { uid: "", domain: "www.codebuddy.cn", enterpriseId: "", refreshToken: "" };
98
+ }
99
+
100
+ // SDK 通道(缺省启用):缺省底层走 @ai-sdk/openai-compatible,`legacy`/关闭词
101
+ // (局部 MSLXDFF_WORKBUDDY_SDK,未设则继承全局 MSLXDFF_UPSTREAM_ENGINE)回退原生 transport;
102
+ // 不可用(未安装)自动回退并告警一次。上层轮换/刷新/reshape 全链复用。
103
+ // Note: 为什么默认 SDK、翻译层代价与回退语义 — 见 .agents/notes/implemented/feature/2026-09-12-workbuddy-sdk-channel.md
104
+ // 上游非标字段(thinking / reasoning_effort / reasoning_content / tool_choice 字符串)不依赖 SDK 白名单:
105
+ // payload.js 前置语义改写 + AI SDK providerOptions 透传(非白名单 key 原样 spread 进 body,2.0.41 实测)。
106
+ // 序列化/流式/错误映射仍全部交 AI SDK;原生 transport 仅作 SDK 不可用时的回退。v0.1.120 实测见 .scratch/probe-sdk-body.mjs。
107
+ const engineMode = resolveEngineMode(process.env, "MSLXDFF_WORKBUDDY_SDK", "sdk");
108
+ let sdkFallbackLogged = false;
109
+ async function fetchOnce(url, body, key, auth) {
110
+ const payload = rewriteWorkbuddyPayload(body);
111
+ if (engineMode === "sdk") {
112
+ try {
113
+ return await attemptOnceSdk({ url, body: payload, key, auth, buildHeaders: buildAuthHeaders, ...(sdkFetch ? { fetchImpl: sdkFetch } : {}) });
114
+ } catch (e) {
115
+ if (!e || !e._sdkLoadFailed) throw e;
116
+ if (!sdkFallbackLogged) {
117
+ sdkFallbackLogged = true;
118
+ try { console.error(`[workbuddy] ${e.message} — 回退原生通道`); } catch {}
119
+ }
120
+ }
121
+ }
122
+ return transport.request({ url, headers: buildAuthHeaders(key, auth), body: { ...payload, stream: true }, stream: true });
123
+ }
124
+
125
+ async function withRefresh(url, body, key, auth) {
126
+ let res;
127
+ try { res = await fetchOnce(url, body, key, auth); }
128
+ catch (e) { throw e; }
129
+ if (res.status < 400) return reshapeWorkbuddySse(res);
130
+ let txt = "";
131
+ try { txt = await res.text(); } catch {}
132
+ // 账号封禁 11140:判账号本体,refresh 无效(2026-10-21 实测刷出新 token 照样 11140),
133
+ // 原样上抛让 runChat 走「长冷却 + 换号」,绝不进 refresh 连锁白烧上游。
134
+ if (isAccountRestricted(res.status, txt)) {
135
+ throw failErr(`workbuddy account restricted (11140) for ${auth?.uid || ""}: ${txt.slice(0, 160)}`, _t0(), clock, { restricted: true, status: res.status });
136
+ }
137
+ if (!isAuthError(res.status, txt)) return rewrapBody(res, txt);
138
+ const newKey = await authService?.refreshTokenFor?.(key, auth);
139
+ if (!newKey) throw failErr(`workbuddy refresh failed for ${auth?.uid || ""}: ${txt.slice(0, 120)}`, _t0(), clock);
140
+ const auth2 = authForKey(newKey);
141
+ let res2;
142
+ try { res2 = await fetchOnce(url, body, newKey, auth2); }
143
+ catch (e2) { throw e2; }
144
+ let stillTxt = "";
145
+ if (res2.status >= 400) { try { stillTxt = await res2.text(); } catch {} }
146
+ // 刷新后仍被封:同属账号级限制,标记 restricted 供上层长冷却。
147
+ if (isAccountRestricted(res2.status, stillTxt)) {
148
+ throw failErr(`workbuddy account restricted (11140) for ${auth?.uid || ""}: ${stillTxt.slice(0, 160)}`, _t0(), clock, { restricted: true, status: res2.status });
149
+ }
150
+ if (stillTxt) res2 = rewrapBody(res2, stillTxt);
151
+ const stillAuth = isAuthError(res2.status, stillTxt);
152
+ const uid2 = auth2.uid || auth.uid;
153
+ res2 = withUidHeader(res2, uid2);
154
+ res2._t = res2._t || { attempts: [], waitMs: 0, totalMs: Math.round(nowMs(clock) - _t0()) };
155
+ if (stillAuth || res2.status === 429 || res2.status >= 500) try { ring.onError(newKey); } catch {}
156
+ doLog({ uid: uid2, model: _modelForLog(), totalMs: Math.round(nowMs(clock) - _t0()), balanceHit: false, error: stillAuth ? `still auth ${res2.status}` : undefined });
157
+ if (stillAuth) throw failErr(`workbuddy auth still failing after refresh for ${uid2}: ${stillTxt.slice(0, 120)}`, _t0(), clock);
158
+ return reshapeWorkbuddySse(res2);
159
+ }
160
+
161
+ let _t0Val = 0;
162
+ let _modelVal = "";
163
+ function _t0() { return _t0Val; }
164
+ function _modelForLog() { return _modelVal; }
165
+
166
+ async function runChat(body, activeRing, opts = {}) {
167
+ const url = joinUrl(baseUrl, chatPath);
168
+ const t0 = nowMs(clock);
169
+ const clean = sanitizeToolSequence(body?.messages);
170
+ if (clean.droppedCalls || clean.droppedResults || clean.movedResults || clean.injectedHead) {
171
+ try { console.error(`[workbuddy] tool-sequence sanitized: dropped ${clean.droppedCalls} call(s), ${clean.droppedResults} result(s), moved ${clean.movedResults} result(s), head+${clean.injectedHead}, model=${body?.model || ""}`); } catch {}
172
+ body = { ...body, messages: clean.messages };
173
+ }
174
+ _t0Val = t0;
175
+ _modelVal = body?.model || "";
176
+ const preferredUid = opts?.workbuddyUid ? String(opts.workbuddyUid).trim() : "";
177
+
178
+ if (preferredUid) {
179
+ const idx = authList.findIndex((a) => a.uid === preferredUid || String(a.uid).startsWith(preferredUid));
180
+ if (idx < 0) return errRes(403, `workbuddy uid not found: ${preferredUid}`, "uid-not-found", preferredUid, t0, clock);
181
+ const key = keys[idx];
182
+ const auth = authList[idx];
183
+ authService?.maybeProactiveRefresh?.(auth, key);
184
+ const cached = getBalance(auth.uid);
185
+ if (cached && Number(cached.total) === 0) return errRes(403, `workbuddy uid in cooldown (balance 0): ${auth.uid}`, "uid-cooling", auth.uid, t0, clock);
186
+ try {
187
+ let res = await withRefresh(url, body, key, auth);
188
+ res = withUidHeader(res, auth.uid);
189
+ res._t = res._t || { attempts: [], waitMs: 0, totalMs: Math.round(nowMs(clock) - t0) };
190
+ if (res.status === 401 || res.status === 403 || res.status === 429 || res.status >= 500) try { activeRing.onError(key); } catch {}
191
+ doLog({ uid: auth.uid, model: _modelVal, totalMs: Math.round(nowMs(clock) - t0), balanceHit: false });
192
+ return res;
193
+ } catch (e) {
194
+ e._t = e._t || { attempts: [], waitMs: 0, totalMs: Math.round(nowMs(clock) - t0) };
195
+ // pin 模式撞封号:长冷却 + 把 403 原文透回(refresh 无效,别冒 502 让客户端以为网关坏了)。
196
+ if (e?._t?.restricted || e?.restricted) {
197
+ try { activeRing.onError(key, restrictedCooldownMs); } catch {}
198
+ doLog({ uid: auth.uid, model: _modelVal, totalMs: Math.round(nowMs(clock) - t0), balanceHit: false, error: String(e.message).slice(0, 160) });
199
+ return errRes(403, String(e.message), "account-restricted", auth.uid, t0, clock);
200
+ }
201
+ if (!(e instanceof Error) || !String(e.message).includes("refresh failed")) try { activeRing.onError(key); } catch {}
202
+ throw e;
203
+ }
204
+ }
205
+
206
+ const tried = new Set();
207
+ const maxTries = Math.max(1, keys.length);
208
+ let lastErr = null;
209
+ for (let triedCount = 0; triedCount < maxTries; triedCount++) {
210
+ const key = activeRing.next();
211
+ if (!key) {
212
+ if (triedCount === 0) {
213
+ // 封号冷却窗口内的重复请求:如实回 403(账号问题),别抛笼统 cooldown 冒成 502。
214
+ if (lastRestricted && clock() - lastRestricted.at < restrictedCooldownMs) {
215
+ return errRes(403, `workbuddy: all account(s) restricted (11140): ${String(lastRestricted.message).slice(0, 160)}`, "account-restricted", "", t0, clock);
216
+ }
217
+ throw failErr(`${id}: all API keys are in cooldown (last error < ${cooldownMs}ms ago) — provider temporarily unavailable`, t0, clock, { cooldownMs });
218
+ }
219
+ break;
220
+ }
221
+ const auth = authForKey(key);
222
+ const uid = auth?.uid || "";
223
+ if (tried.has(uid || key)) continue;
224
+ tried.add(uid || key);
225
+ const cached = uid ? getBalance(uid) : null;
226
+ if (cached && Number(cached.total) === 0) {
227
+ try { activeRing.onError(key); } catch {}
228
+ setBalance(uid, { ...cached, total: 0 });
229
+ doLog({ uid, model: _modelVal, totalMs: Math.round(nowMs(clock) - t0), balanceHit: true });
230
+ if (keys.length === 1 || activeRing.available() === 0) return errRes(403, `workbuddy uid in cooldown (balance 0): ${uid}`, "uid-cooling", uid, t0, clock);
231
+ continue;
232
+ }
233
+ if (!key && activeRing.size === 0) throw failErr(`${id}: missing MSLXDFF_WORKBUDDY_KEY (chat requires a real key) — run node workbuddy-token-auto.js`, t0, clock);
234
+ authService?.maybeProactiveRefresh?.(auth, key);
235
+ let res;
236
+ try { res = await withRefresh(url, body, key, auth); }
237
+ catch (e) {
238
+ e._t = e._t || { attempts: [], waitMs: 0, totalMs: Math.round(nowMs(clock) - t0) };
239
+ // 封号 11140:长冷却本号换下一个;冷却档走 restrictedCooldownMs(缺省 15min),别用 30s 短档反复白烧。
240
+ // restricted 标记挂在 failErr 的 _t 上;SDK 通道对 4xx 抛的 AI_APICallError 带 statusCode/responseBody,一并识别。
241
+ if (e?._t?.restricted || e?.restricted || isAccountRestricted(e?.statusCode, e?.responseBody)) {
242
+ lastRestricted = { at: clock(), uid, message: String(e.responseBody || e.message || "").slice(0, 200) };
243
+ try { activeRing.onError(key, restrictedCooldownMs); } catch {}
244
+ } else try { activeRing.onError(key); } catch {}
245
+ lastErr = e;
246
+ continue;
247
+ }
248
+ if (res.status === 402 || res.status === 403 || res.status === 429) {
249
+ let txt = "";
250
+ try { txt = await res.text(); } catch {}
251
+ if (isInsufficientStatus(res.status, txt, cached)) {
252
+ try { activeRing.onError(key); } catch {}
253
+ if (uid) setBalance(uid, { total: 0, dailyPacks: 0, activeCount: 0, nextExpire: null, fetchedAt: clock() });
254
+ doLog({ uid, model: _modelVal, totalMs: Math.round(nowMs(clock) - t0), balanceHit: true, error: txt.slice(0, 120) });
255
+ lastErr = failErr(`workbuddy insufficient for ${uid}: ${txt.slice(0, 120)}`, t0, clock);
256
+ continue;
257
+ }
258
+ // 频率限制(腾讯 code 6004):这个号撞了限流,但**额度还在**(total 可能还有几千点)。
259
+ // 必须换下一个号顶,且绝不能 setBalance(total:0) —— 那会把好号永久跳过,
260
+ // 是「429 原样吐回客户端、从不换号」在 2026-10-17 实锤的那条老路。
261
+ // Note: 6004 的判据与冷却分档 — 见 .agents/notes/implemented/bug-fix/2026-10-18-workbuddy-6004-rate-limit-rotate.md
262
+ if (isRateLimitedStatus(res.status, txt)) {
263
+ const resetMs = parseRateLimitResetMs(txt, clock);
264
+ // 冷却时长取「上游给的重置时刻」与固定下限的较大者;解析不出就只按固定档,
265
+ // 且**封顶 10 分钟**:6004 的重置时间可能远在数小时后,照抄会把号冻到下班。
266
+ const coolMs = Math.min(Math.max(resetMs || 0, cooldownMs), 10 * 60_000);
267
+ try { activeRing.onError(key, coolMs); } catch {}
268
+ doLog({ uid, model: _modelVal, totalMs: Math.round(nowMs(clock) - t0), balanceHit: false, error: `rate-limited(${coolMs}ms) ${txt.slice(0, 120)}` });
269
+ lastErr = failErr(`workbuddy rate limited for ${uid}: ${txt.slice(0, 120)}`, t0, clock);
270
+ if (activeRing.available() > 0) continue;
271
+ res = rewrapBody(res, txt);
272
+ } else {
273
+ res = rewrapBody(res, txt);
274
+ }
275
+ }
276
+ if (res.status === 401 || res.status === 403 || res.status === 429 || res.status >= 500) try { activeRing.onError(key); } catch {}
277
+ res = withUidHeader(res, uid);
278
+ res._t = res._t || { attempts: [], waitMs: 0, totalMs: Math.round(nowMs(clock) - t0) };
279
+ doLog({ uid, model: _modelVal, totalMs: Math.round(nowMs(clock) - t0), balanceHit: false });
280
+ return res;
281
+ }
282
+ if (lastErr) {
283
+ // 全号皆被封 11140:上游报文带可申诉的 displayMsg/displayTips,透回原文比模糊包装更利于用户自查;
284
+ // 状态码用上游的 403(旧链是 502,客户端侧像网关坏了,实为账号问题)。
285
+ if (lastErr._t?.restricted || lastErr.restricted) {
286
+ lastRestricted = { at: clock(), uid: "", message: String(lastErr.message || "") };
287
+ const uids = [...tried].filter((x) => x && x.includes("-")).join(",");
288
+ const reason = `workbuddy all account(s) restricted (11140): ${uids || "n/a"} — ${String(lastErr.message).slice(0, 200)}`;
289
+ const r = errRes(403, reason, "account-restricted", uids, t0, clock);
290
+ return r;
291
+ }
292
+ throw lastErr;
293
+ }
294
+ throw failErr(`${id}: all workbuddy accounts exhausted or unavailable`, t0, clock);
295
+ }
296
+
297
+ return { runChat, authForKey, buildAuthHeaders, withUidHeader, attemptOnce: fetchOnce };
298
+ }