mslxdff 0.1.236 → 0.1.237

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 (102) hide show
  1. package/bin/mslxdff.js +3 -3
  2. package/package.json +1 -1
  3. package/src/auto.js +264 -264
  4. package/src/autostart.js +62 -15
  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/cli/commands/group.js +266 -266
  22. package/src/cli/commands/model/picks.js +50 -50
  23. package/src/cli/commands/provider/bench-via.js +247 -247
  24. package/src/cli/commands/provider/bench.js +141 -141
  25. package/src/cli/commands/provider/index.js +139 -139
  26. package/src/cli/commands/provider/models.js +149 -149
  27. package/src/cli/commands/provider/qwenwork-login.js +119 -119
  28. package/src/cli/commands/provider/raccoon-quota.js +156 -156
  29. package/src/cli/commands/sync.js +310 -310
  30. package/src/cli/status.js +282 -282
  31. package/src/daemon.js +246 -246
  32. package/src/model-capabilities/enrich.js +86 -86
  33. package/src/model-capabilities/index.js +183 -183
  34. package/src/model-capabilities/parse.js +70 -70
  35. package/src/models.js +293 -293
  36. package/src/peers.js +389 -389
  37. package/src/providers/agentrouter.js +238 -238
  38. package/src/providers/base.js +164 -164
  39. package/src/providers/cline/auth.js +228 -228
  40. package/src/providers/cline/chat.js +307 -307
  41. package/src/providers/cline.js +2 -2
  42. package/src/providers/codearts/chat.js +207 -207
  43. package/src/providers/dispatcher.js +147 -147
  44. package/src/providers/globalqwenwork/cosy.js +123 -123
  45. package/src/providers/globalqwenwork/crypto.js +218 -218
  46. package/src/providers/globalqwenwork/http.js +20 -20
  47. package/src/providers/globalqwenwork/rsa.js +54 -54
  48. package/src/providers/globalqwenwork/sse.js +268 -268
  49. package/src/providers/globalqwenwork/stream.js +130 -130
  50. package/src/providers/globalqwenwork/upstream.js +122 -122
  51. package/src/providers/keyring.js +60 -60
  52. package/src/providers/qoder/chat.js +292 -292
  53. package/src/providers/qoder/index.js +311 -311
  54. package/src/providers/qoder/sse.js +233 -233
  55. package/src/providers/qwenwork/account-store.js +133 -133
  56. package/src/providers/qwenwork/constants.js +67 -67
  57. package/src/providers/qwenwork/cosy.js +120 -120
  58. package/src/providers/qwenwork/crypto.js +218 -218
  59. package/src/providers/qwenwork/http.js +20 -20
  60. package/src/providers/qwenwork/index.js +337 -337
  61. package/src/providers/qwenwork/payload.js +144 -144
  62. package/src/providers/qwenwork/rsa.js +54 -54
  63. package/src/providers/qwenwork/sse.js +268 -268
  64. package/src/providers/qwenwork/stream.js +130 -130
  65. package/src/providers/qwenwork/upstream.js +120 -120
  66. package/src/providers/qwenwork.js +1 -1
  67. package/src/providers/raccoon/const.js +219 -219
  68. package/src/providers/raccoon/credits.js +285 -285
  69. package/src/providers/registry.js +89 -89
  70. package/src/providers/workbuddy/chat.js +298 -298
  71. package/src/providers/workbuddy/reshape.js +152 -152
  72. package/src/providers/workbuddy.js +2 -2
  73. package/src/reasoning.js +32 -32
  74. package/src/routes/chat/broadband-handler.js +80 -80
  75. package/src/routes/chat/gateway.js +46 -46
  76. package/src/routes/chat/relay-pipeline.js +282 -282
  77. package/src/routes/chat/via-route-handler.js +158 -158
  78. package/src/routes/hedge.js +260 -260
  79. package/src/routes/models-route.js +167 -167
  80. package/src/routes/peers.js +373 -373
  81. package/src/routes/stream.js +364 -364
  82. package/src/runtime/bootstrap.js +75 -75
  83. package/src/runtime/keep-warm.js +135 -135
  84. package/src/runtime/provider-gate.js +75 -75
  85. package/src/runtime/providers-setup.js +187 -187
  86. package/src/runtime/raccoon-checkin.js +175 -175
  87. package/src/runtime/server-lifecycle.js +34 -10
  88. package/src/server.js +85 -85
  89. package/src/state/schemas/allowlist.js +92 -92
  90. package/src/sync-opencode.js +280 -280
  91. package/src/transport/index.js +244 -244
  92. package/src/transport/pool.js +56 -56
  93. package/src/transport/retry.js +24 -24
  94. package/src/transport/sse.js +93 -93
  95. package/src/upstream-probe/display.js +52 -52
  96. package/src/upstream-probe/probe.js +49 -49
  97. package/src/upstream-probe/rotate.js +110 -110
  98. package/src/upstream-probe/start.js +45 -45
  99. package/src/upstream.js +355 -355
  100. package/docs/cli_help_mini.md +0 -149
  101. package/src/providers/AGENTS.md +0 -49
  102. package/src/routes/AGENTS.md +0 -37
@@ -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
+ }