mslxdff 0.1.231 → 0.1.232

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 (104) hide show
  1. package/bin/mslxdff.js +3 -3
  2. package/package.json +1 -1
  3. package/src/auto.js +254 -254
  4. package/src/bench/cline-bench.js +42 -42
  5. package/src/bench/probe.js +70 -70
  6. package/src/bench/report.js +162 -162
  7. package/src/bench/runner.js +77 -77
  8. package/src/bench/via-probe.js +124 -124
  9. package/src/bench/via-routes.js +87 -87
  10. package/src/bench/workbuddy-bench.js +54 -54
  11. package/src/chat/engine.js +160 -160
  12. package/src/chat/gateway.js +163 -163
  13. package/src/chat/orchestrator.js +234 -234
  14. package/src/chat/prompt.js +70 -70
  15. package/src/chat/repl.js +88 -88
  16. package/src/chat/terminal.js +135 -135
  17. package/src/chat/tools.js +306 -306
  18. package/src/chat-pipeline/index.js +145 -145
  19. package/src/chat-pipeline/policy.js +84 -84
  20. package/src/cli/commands/group.js +266 -266
  21. package/src/cli/commands/model/picks.js +50 -50
  22. package/src/cli/commands/provider/bench-via.js +247 -247
  23. package/src/cli/commands/provider/bench.js +141 -141
  24. package/src/cli/commands/provider/index.js +139 -139
  25. package/src/cli/commands/provider/models.js +149 -149
  26. package/src/cli/commands/provider/qwenwork-login.js +119 -119
  27. package/src/cli/commands/provider/raccoon-quota.js +156 -156
  28. package/src/cli/commands/sync.js +310 -310
  29. package/src/cli/status.js +279 -279
  30. package/src/daemon.js +246 -246
  31. package/src/model-capabilities/enrich.js +86 -86
  32. package/src/model-capabilities/index.js +183 -183
  33. package/src/model-capabilities/parse.js +70 -70
  34. package/src/models.js +225 -225
  35. package/src/peers.js +345 -345
  36. package/src/providers/agentrouter.js +238 -238
  37. package/src/providers/base.js +164 -164
  38. package/src/providers/cline/auth.js +228 -228
  39. package/src/providers/cline/chat.js +307 -307
  40. package/src/providers/cline.js +2 -2
  41. package/src/providers/codearts/chat.js +207 -207
  42. package/src/providers/dispatcher.js +147 -147
  43. package/src/providers/globalqwenwork/cosy.js +123 -123
  44. package/src/providers/globalqwenwork/crypto.js +218 -218
  45. package/src/providers/globalqwenwork/http.js +20 -20
  46. package/src/providers/globalqwenwork/rsa.js +54 -54
  47. package/src/providers/globalqwenwork/sse.js +268 -268
  48. package/src/providers/globalqwenwork/stream.js +130 -130
  49. package/src/providers/globalqwenwork/upstream.js +122 -122
  50. package/src/providers/keyring.js +60 -60
  51. package/src/providers/qoder/chat.js +203 -203
  52. package/src/providers/qoder/index.js +249 -249
  53. package/src/providers/qoder/sse.js +145 -145
  54. package/src/providers/qwenwork/account-store.js +133 -133
  55. package/src/providers/qwenwork/constants.js +67 -67
  56. package/src/providers/qwenwork/cosy.js +120 -120
  57. package/src/providers/qwenwork/crypto.js +218 -218
  58. package/src/providers/qwenwork/http.js +20 -20
  59. package/src/providers/qwenwork/index.js +337 -337
  60. package/src/providers/qwenwork/payload.js +144 -144
  61. package/src/providers/qwenwork/rsa.js +54 -54
  62. package/src/providers/qwenwork/sse.js +268 -268
  63. package/src/providers/qwenwork/stream.js +130 -130
  64. package/src/providers/qwenwork/upstream.js +120 -120
  65. package/src/providers/qwenwork.js +1 -1
  66. package/src/providers/raccoon/const.js +202 -202
  67. package/src/providers/raccoon/credits.js +285 -285
  68. package/src/providers/registry.js +89 -89
  69. package/src/providers/workbuddy/auth.js +16 -0
  70. package/src/providers/workbuddy/chat.js +298 -264
  71. package/src/providers/workbuddy/client-headers.js +148 -0
  72. package/src/providers/workbuddy/desktop-info.js +17 -1
  73. package/src/providers/workbuddy/models.js +8 -20
  74. package/src/providers/workbuddy/reshape.js +152 -152
  75. package/src/providers/workbuddy.js +2 -2
  76. package/src/reasoning.js +32 -32
  77. package/src/routes/chat/broadband-handler.js +80 -80
  78. package/src/routes/chat/gateway.js +46 -46
  79. package/src/routes/chat/relay-pipeline.js +282 -282
  80. package/src/routes/chat/via-route-handler.js +156 -156
  81. package/src/routes/hedge.js +260 -260
  82. package/src/routes/models-route.js +167 -167
  83. package/src/routes/peers.js +373 -373
  84. package/src/routes/stream.js +347 -347
  85. package/src/runtime/bootstrap.js +75 -75
  86. package/src/runtime/keep-warm.js +135 -135
  87. package/src/runtime/provider-gate.js +75 -75
  88. package/src/runtime/providers-setup.js +187 -187
  89. package/src/runtime/raccoon-checkin.js +175 -175
  90. package/src/server.js +85 -85
  91. package/src/state/schemas/allowlist.js +92 -92
  92. package/src/sync-opencode.js +280 -280
  93. package/src/transport/index.js +244 -244
  94. package/src/transport/pool.js +56 -56
  95. package/src/transport/retry.js +24 -24
  96. package/src/transport/sse.js +93 -93
  97. package/src/upstream-probe/display.js +52 -52
  98. package/src/upstream-probe/probe.js +49 -49
  99. package/src/upstream-probe/rotate.js +110 -110
  100. package/src/upstream-probe/start.js +45 -45
  101. package/src/upstream.js +355 -355
  102. package/docs/cli_help_mini.md +0 -148
  103. package/src/providers/AGENTS.md +0 -49
  104. package/src/routes/AGENTS.md +0 -37
package/src/upstream.js CHANGED
@@ -1,355 +1,355 @@
1
- import crypto from "node:crypto";
2
- import { mkdirSync } from "node:fs";
3
- import { appendFile } from "node:fs/promises";
4
- import { dirname, join } from "node:path";
5
- import { performance } from "node:perf_hooks";
6
- import { isFreeModel } from "./models.js";
7
- import { fmtShanghaiYMDHMS } from "./time.js";
8
- import { createTransport } from "./transport/index.js";
9
- import { isResponsesModel, chatToResponsesBody, toChatResponse, reshapeResponsesSse } from "./upstream-responses.js";
10
- import { ensureFreeLaneShape, aggregateChatSse } from "./free-lane.js";
11
- import { digestIdTail, genId, opencodeClientIdentity, opencodeUa } from "./opencode-identity.js";
12
-
13
- export { opencodeClientIdentity, opencodeUa };
14
- // 上游按 session 做粘性路由(实测:固定 session 两次请求均 ~1.2s;每次随机时可能撞冷机器 26s+)。
15
- // 客户端(opencode AI SDK 路径)不带会话标识 → 用对话首两条消息(system + 首条 user)哈希做稳定会话:
16
- // 同一会话多轮里这两条不变 ⇒ 路由亲和稳定;不同会话天然分散。
17
- function sessionFromMessages(messages) {
18
- try {
19
- const msgs = Array.isArray(messages) ? messages : [];
20
- const pick = (role) => {
21
- const m = msgs.find((x) => x?.role === role);
22
- if (!m) return "";
23
- return typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? "");
24
- };
25
- const seed = `${pick("system")}|${pick("user")}`.slice(0, 4000);
26
- if (seed === "|") return null;
27
- return `ses_${digestIdTail(crypto.createHash("sha1").update(seed).digest())}`;
28
- } catch {
29
- return null;
30
- }
31
- }
32
- function envInt(name, fallback) {
33
- const v = Number(process.env[name]);
34
- return Number.isInteger(v) && v > 0 ? v : fallback;
35
- }
36
- /** 允许 0 的数值 env(G1a 的逃生阀要认 0=关;envInt 的 `v>0` 会把 0 吞回默认值,那就是个假开关)。 */
37
- function envIntOrZero(name, fallback) {
38
- const v = Number(process.env[name]);
39
- return Number.isInteger(v) && v >= 0 ? v : fallback;
40
- }
41
- function isPreheatDisabled() {
42
- const raw = process.env.MSLXDFF_PREHEAT;
43
- if (raw === undefined || raw === null || raw === "") return false;
44
- const s = String(raw).trim().toLowerCase();
45
- return s === "0" || s === "off" || s === "false" || s === "no" || s === "disable" || s === "disabled";
46
- }
47
- // opencode 上游身份头单一来源(legacy 与 SDK 引擎共用,free 层门禁头必须一致)。
48
- // anonFirst 语义:authToken==="public" 时默认匿名(hermes referer);env 可显式覆盖。
49
- export function createOpencodeHeaderBuilder({ authToken = "public", env = process.env } = {}) {
50
- function isAnonFirst() {
51
- const raw = env.MSLXDFF_OPENCOD_ANON_FIRST ?? env.MSLXDFF_ANON_FIRST ?? env.MSLXDFF_OPENCOD_ANON;
52
- if (raw === undefined || raw === null || raw === "") return authToken === "public";
53
- const s = String(raw).trim().toLowerCase();
54
- if (s === "0" || s === "false" || s === "off" || s === "no" || s === "disable" || s === "disabled") return false;
55
- return authToken === "public";
56
- }
57
- const anonFirst = isAnonFirst();
58
- const baseHeaders = anonFirst
59
- ? {
60
- "Content-Type": "application/json",
61
- Authorization: "",
62
- "x-opencode-client": "desktop",
63
- "User-Agent": opencodeUa(),
64
- "HTTP-Referer": "https://hermes-agent.nousresearch.com",
65
- "X-Title": "Hermes Agent",
66
- }
67
- : {
68
- "Content-Type": "application/json",
69
- Authorization: `Bearer ${authToken}`,
70
- "x-opencode-client": "desktop",
71
- };
72
- // 无 messages 可哈希时的进程级兜底:至少 daemon 生命周期内稳定,不再每请求随机
73
- const FALLBACK_SESSION = genId("ses_");
74
- function buildHeaders(body, { anonymous = anonFirst, sessionId = null } = {}) {
75
- const isStream = body?.stream !== false;
76
- const session = sessionId || sessionFromMessages(body?.messages) || FALLBACK_SESSION;
77
- const base = {
78
- ...baseHeaders,
79
- Accept: isStream ? "text/event-stream" : "*/*",
80
- "User-Agent": opencodeUa(),
81
- "x-opencode-session": session,
82
- "x-opencode-request": genId("msg_"),
83
- "x-opencode-project": "global",
84
- };
85
- if (anonymous) {
86
- return { ...base, Authorization: "", "HTTP-Referer": "https://hermes-agent.nousresearch.com", "X-Title": "Hermes Agent" };
87
- }
88
- if (anonFirst) {
89
- const { "HTTP-Referer": _a, "X-Title": _b, ...rest } = base;
90
- return { ...rest, Authorization: `Bearer ${authToken}` };
91
- }
92
- return base;
93
- }
94
- return { buildHeaders, anonFirst };
95
- }
96
-
97
- export function createUpstreamClient({
98
- baseUrl = process.env.UPSTREAM_BASE_URL || "https://opencode.ai",
99
- authToken = process.env.UPSTREAM_AUTH_TOKEN || "public",
100
- connectTimeoutMs = Number(process.env.UPSTREAM_CONNECT_TIMEOUT_MS) || 30_000,
101
- retry = {
102
- network: { attempts: 2, delayMs: 300 },
103
- 429: { attempts: 1, delayMs: 100 },
104
- 502: { attempts: 1, delayMs: 100 },
105
- 503: { attempts: 1, delayMs: 100 },
106
- 504: { attempts: 1, delayMs: 100 },
107
- },
108
- fetchImpl,
109
- hooks,
110
- keepAlive = true,
111
- dispatcher,
112
- } = {}) {
113
- const { buildHeaders, anonFirst } = createOpencodeHeaderBuilder({ authToken });
114
- function shouldTryAnonFree() {
115
- const raw = process.env.MSLXDFF_FREE_ANON;
116
- if (raw === "0" || raw === "off" || raw === "false" || raw === "no") return false;
117
- return true;
118
- }
119
- function freeAnonLogFile() {
120
- return process.env.MSLXDFF_FREE_ANON_LOG || join(process.cwd(), "free-anon-extra.txt");
121
- }
122
- function logFreeAnon({ model, publicStatus, anonStatus, anonAttempts, hit, totalMs }) {
123
- const file = freeAnonLogFile();
124
- const line = `${fmtShanghaiYMDHMS(new Date())} model=${model} public=429 anonTries=${anonAttempts} anonStatus=${anonStatus ?? "none"} hit=${hit ? "YES额外额度" : "NO无额外"} totalMs=${totalMs} count=${hit ? "1" : "0"}\n`;
125
- try { mkdirSync(dirname(file), { recursive: true }); } catch {}
126
- appendFile(file, line).catch(() => {});
127
- try { if (process.env.MSLXDFF_DEBUG === "1") console.log(`[free-anon] ${line.trim()}`); } catch {}
128
- }
129
- let consecutiveHits = 0;
130
-
131
- // 深模块:网络层委托 StreamingTransport
132
- const transport = createTransport({
133
- keepAlive,
134
- dispatcher,
135
- fetchImpl,
136
- timeoutMs: connectTimeoutMs,
137
- retry,
138
- hooks,
139
- });
140
-
141
- async function chat(body, opts = {}) {
142
- const isResp = isResponsesModel(body?.model);
143
- // responses 路径的 reqBody 无 messages,会话哈希必须基于原始 chat body 计算
144
- const sessionId = opts?.sessionId || sessionFromMessages(body?.messages) || null;
145
- const url = isResp ? `${baseUrl}/zen/v1/responses` : `${baseUrl}/zen/v1/chat/completions`;
146
- const reqBody = isResp ? chatToResponsesBody(body) : body;
147
- const clientWantsStream = body?.stream !== false;
148
- // zen 免费层 agent 形状门禁(2026-09-18):必须流式 + 含核心五工具名,否则 403 FreeTierError
149
- const freeLane = authToken === "public" && isFreeModel(body?.model);
150
- if (freeLane) ensureFreeLaneShape(reqBody, { responses: isResp });
151
- const laneDebug = freeLane && envInt("MSLXDFF_FREE_LANE_DEBUG", 0) === 1;
152
- if (laneDebug) {
153
- try { console.error(`[free-lane] send model=${body?.model} url=${url} stream=${reqBody.stream} tools=${(reqBody.tools || []).length} ua=${opencodeUa()} want=${clientWantsStream}`); } catch {}
154
- }
155
- // 非流式调用者实际拿到的是被强制流式的上游 → 聚合回 chat completion JSON
156
- async function agentJson(resp) {
157
- if (!(freeLane && !clientWantsStream && resp.ok)) return resp;
158
- const ct = resp.headers?.get?.("content-type") || "";
159
- if (!ct.includes("text/event-stream")) return resp;
160
- try {
161
- const out = await aggregateChatSse(resp);
162
- out._t = resp._t;
163
- return out;
164
- } catch {
165
- return resp;
166
- }
167
- }
168
- const t0 = performance.now();
169
-
170
- // 首发请求(transport 已处理 network/429 等重试)
171
- let res;
172
- const firstHeaders = buildHeaders(reqBody, { sessionId });
173
- try {
174
- res = await transport.request({
175
- url,
176
- method: "POST",
177
- headers: firstHeaders,
178
- body: reqBody,
179
- stream: reqBody.stream !== false,
180
- timeoutMs: connectTimeoutMs,
181
- retry,
182
- });
183
- } catch (e) {
184
- e._t = e._t || { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - t0) };
185
- throw e;
186
- }
187
- if (laneDebug) {
188
- try { console.error(`[free-lane] resp model=${body?.model} status=${res.status} ct=${res.headers?.get?.("content-type") || ""}`); } catch {}
189
- }
190
-
191
- // 请求内换出口(v6 池):撞 429 时在同一请求里换源地址重发。
192
- //
193
- // ⚠ 顺序契约(用户 2026-10-19 明确):**先借人,再换出口**。
194
- // 429 ⇒ ① 先走组员/组长接力(用**对方的 IPv4 出口**);② 全都没得用/都失败,
195
- // 才轮到 WARP / IPv6 换出口拿"新的自然额度"。WARP/IPv6 是另一层兜底,不该抢在借人之前。
196
- // 故本功能**默认关闭**,需要时才显式开:MSLXDFF_V6_INREQUEST_ROTATE=1(原 ADR-0061 行为)。
197
- // 关闭时 429 原样交回上层 → chat-pipeline 去试组员/组长,符合用户要的顺序。
198
- // 边界(探测实证过的硬约束):① 仅头部即 429 才换(不碰半开的 200 流);② 429 错误体已被
199
- // transport 非流分支读尽,无连接泄漏;③ 每轮换的是 session 粘的那个槽(swap 原地换 addr/e.agent,
200
- // 粘性映射不失效);④ 次数上限防 429 风暴空烧认领地址;⑤ 无 rotateFor 的 dispatcher(直连/WARP)
201
- // 条件短路——WARP 换出口=重注册设备 6s~600s,在请求内做只会白等,**设计上排除**。
202
- const inRequestRotateOn = (() => {
203
- const raw = process.env.MSLXDFF_V6_INREQUEST_ROTATE;
204
- if (raw === undefined || raw === null || raw === "") return false; // 缺省关:先借人
205
- const s = String(raw).trim().toLowerCase();
206
- return !(s === "0" || s === "false" || s === "off" || s === "no");
207
- })();
208
- if (inRequestRotateOn && freeLane && res.status === 429 && typeof transport.dispatcher?.rotateFor === "function") {
209
- const maxRot = envIntOrZero("MSLXDFF_V6_INREQUEST_RETRIES", 3); // 0 = 关掉请求内换出口
210
- let rotated = 0;
211
- while (res.status === 429 && rotated < maxRot) {
212
- let next = null;
213
- try {
214
- // 用**线上传的那个** session 头定位粘性槽(无 messages 时 buildHeaders 兜底成进程级
215
- // FALLBACK_SESSION;传 null 会让 selector 轮转到别的槽,换掉一个没准真没耗尽的健康出口)
216
- next = await transport.dispatcher.rotateFor(firstHeaders["x-opencode-session"] || "");
217
- } catch { break; }
218
- if (!next?.agent) break; // 换不到新出口(空池/全在换址/认领失败)⇒ 正常收场,不黑洞
219
- rotated += 1;
220
- if (laneDebug) {
221
- try { console.error(`[free-lane] v6 in-request rotate #${rotated} ${next.from} -> ${next.addr} model=${body?.model}`); } catch {}
222
- }
223
- try {
224
- res = await transport.request({
225
- url,
226
- method: "POST",
227
- headers: buildHeaders(reqBody, { sessionId }),
228
- body: reqBody,
229
- stream: reqBody.stream !== false,
230
- timeoutMs: connectTimeoutMs,
231
- retry,
232
- dispatcher: next.agent, // per-call 出口:绕开 extDispatcher 的旧槽选择
233
- });
234
- } catch (e) {
235
- e._t = e._t || { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - t0) };
236
- throw e;
237
- }
238
- }
239
- if (rotated) {
240
- res._t = { ...(res._t || {}), egressRotations: rotated, egressRecovered: res.status < 400 };
241
- try {
242
- appendFile(freeAnonLogFile(), `${fmtShanghaiYMDHMS(new Date())} model=${body?.model} v6-in-request-rotations=${rotated} finalStatus=${res.status}\n`).catch(() => {});
243
- } catch {}
244
- }
245
- }
246
-
247
- // 匿名兜底:仅非 anonFirst 时,public 429 + free 模型才走 hermes 空头重试
248
- if (!anonFirst && res.status === 429 && isFreeModel(body?.model) && shouldTryAnonFree()) {
249
- const anonRetries = envInt("MSLXDFF_FREE_ANON_RETRIES", 3);
250
- const anonDelay = envInt("MSLXDFF_FREE_ANON_DELAY_MS", 1000);
251
- let anonRes = null;
252
- for (let i = 0; i < anonRetries; i++) {
253
- await new Promise((r) => setTimeout(r, anonDelay));
254
- try {
255
- anonRes = await transport.request({
256
- url,
257
- method: "POST",
258
- headers: buildHeaders(reqBody, { anonymous: true, sessionId }),
259
- body: reqBody,
260
- stream: reqBody.stream !== false,
261
- timeoutMs: connectTimeoutMs,
262
- retry,
263
- });
264
- } catch (e) {
265
- continue;
266
- }
267
- if (anonRes.status !== 429) {
268
- // responses 模型需转回 chat 形状(复用 upstream-responses)
269
- let outAnon = anonRes;
270
- if (isResp && anonRes.ok) {
271
- const ctAnon = anonRes.headers.get("content-type") || "";
272
- const isStreamAnon = (clientWantsStream || freeLane) && ctAnon.includes("text/event-stream");
273
- if (isStreamAnon) {
274
- outAnon = reshapeResponsesSse(anonRes, body.model);
275
- } else {
276
- try {
277
- const txt = await anonRes.text();
278
- const j = JSON.parse(txt);
279
- if (j && Array.isArray(j.output)) {
280
- outAnon = toChatResponse(anonRes, j);
281
- } else {
282
- outAnon = new Response(txt, { status: anonRes.status, headers: anonRes.headers });
283
- }
284
- } catch { outAnon = anonRes; }
285
- }
286
- }
287
- outAnon._t = { ...(outAnon._t || {}), anonTried: true, anonAttempts: i + 1, totalMs: Math.round(performance.now() - t0) };
288
- consecutiveHits += 1;
289
- logFreeAnon({ model: body?.model, publicStatus: 429, anonStatus: anonRes.status, anonAttempts: i + 1, hit: true, totalMs: outAnon._t.totalMs });
290
- if (consecutiveHits >= 2) {
291
- try { appendFile(freeAnonLogFile(), ` -> 连续额外额度 ${consecutiveHits} 次\n`).catch(() => {}); } catch {}
292
- }
293
- return await agentJson(outAnon);
294
- }
295
- }
296
- if (anonRes) {
297
- anonRes._t = { ...(anonRes._t || {}), anonTried: true, anonAttempts: anonRetries, totalMs: Math.round(performance.now() - t0) };
298
- consecutiveHits = 0;
299
- logFreeAnon({ model: body?.model, publicStatus: 429, anonStatus: anonRes.status, anonAttempts: anonRetries, hit: false, totalMs: anonRes._t.totalMs });
300
- return anonRes;
301
- }
302
- }
303
-
304
- // responses 模型成功态转 chat(复用 upstream-responses)
305
- if (isResp && res.ok) {
306
- const ct = res.headers.get("content-type") || "";
307
- const isStream = (clientWantsStream || freeLane) && ct.includes("text/event-stream");
308
- if (isStream) {
309
- const transformed = reshapeResponsesSse(res, body.model);
310
- transformed._t = { ...(res._t || {}), totalMs: Math.round(performance.now() - t0) };
311
- return await agentJson(transformed);
312
- }
313
- try {
314
- const txt = await res.text();
315
- const j = JSON.parse(txt);
316
- if (j && Array.isArray(j.output)) {
317
- const transformed = toChatResponse(res, j);
318
- transformed._t = { ...(res._t || {}), totalMs: Math.round(performance.now() - t0) };
319
- return transformed;
320
- }
321
- const fallback = new Response(txt, { status: res.status, headers: res.headers });
322
- fallback._t = { ...(res._t || {}), totalMs: Math.round(performance.now() - t0) };
323
- return fallback;
324
- } catch {
325
- res._t = { ...(res._t || {}), totalMs: Math.round(performance.now() - t0) };
326
- return res;
327
- }
328
- }
329
- res._t = { ...(res._t || {}), totalMs: res._t?.totalMs ?? Math.round(performance.now() - t0) };
330
- return await agentJson(res);
331
- }
332
-
333
- async function preheat() {
334
- if (isPreheatDisabled()) return { ok: false, skipped: true, reason: "disabled" };
335
- const t0 = performance.now();
336
- try {
337
- const res = await transport.request({
338
- url: `${baseUrl}/zen/v1/models`,
339
- method: "GET",
340
- headers: buildHeaders({ stream: false }),
341
- stream: false,
342
- timeoutMs: 3000,
343
- });
344
- try { await res.text().catch(() => {}); } catch {}
345
- return { ok: res.ok, status: res.status, ms: Math.round(performance.now() - t0) };
346
- } catch (e) {
347
- return { ok: false, error: String(e?.message || e), ms: Math.round(performance.now() - t0) };
348
- }
349
- }
350
- async function close() {
351
- await transport.close();
352
- }
353
- const headers = buildHeaders({});
354
- return { chat, preheat, close, headers, buildHeaders, get dispatcher() { return transport.dispatcher; }, get agent() { return transport.agent; }, [Symbol.asyncDispose]: close };
355
- }
1
+ import crypto from "node:crypto";
2
+ import { mkdirSync } from "node:fs";
3
+ import { appendFile } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
+ import { performance } from "node:perf_hooks";
6
+ import { isFreeModel } from "./models.js";
7
+ import { fmtShanghaiYMDHMS } from "./time.js";
8
+ import { createTransport } from "./transport/index.js";
9
+ import { isResponsesModel, chatToResponsesBody, toChatResponse, reshapeResponsesSse } from "./upstream-responses.js";
10
+ import { ensureFreeLaneShape, aggregateChatSse } from "./free-lane.js";
11
+ import { digestIdTail, genId, opencodeClientIdentity, opencodeUa } from "./opencode-identity.js";
12
+
13
+ export { opencodeClientIdentity, opencodeUa };
14
+ // 上游按 session 做粘性路由(实测:固定 session 两次请求均 ~1.2s;每次随机时可能撞冷机器 26s+)。
15
+ // 客户端(opencode AI SDK 路径)不带会话标识 → 用对话首两条消息(system + 首条 user)哈希做稳定会话:
16
+ // 同一会话多轮里这两条不变 ⇒ 路由亲和稳定;不同会话天然分散。
17
+ function sessionFromMessages(messages) {
18
+ try {
19
+ const msgs = Array.isArray(messages) ? messages : [];
20
+ const pick = (role) => {
21
+ const m = msgs.find((x) => x?.role === role);
22
+ if (!m) return "";
23
+ return typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? "");
24
+ };
25
+ const seed = `${pick("system")}|${pick("user")}`.slice(0, 4000);
26
+ if (seed === "|") return null;
27
+ return `ses_${digestIdTail(crypto.createHash("sha1").update(seed).digest())}`;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+ function envInt(name, fallback) {
33
+ const v = Number(process.env[name]);
34
+ return Number.isInteger(v) && v > 0 ? v : fallback;
35
+ }
36
+ /** 允许 0 的数值 env(G1a 的逃生阀要认 0=关;envInt 的 `v>0` 会把 0 吞回默认值,那就是个假开关)。 */
37
+ function envIntOrZero(name, fallback) {
38
+ const v = Number(process.env[name]);
39
+ return Number.isInteger(v) && v >= 0 ? v : fallback;
40
+ }
41
+ function isPreheatDisabled() {
42
+ const raw = process.env.MSLXDFF_PREHEAT;
43
+ if (raw === undefined || raw === null || raw === "") return false;
44
+ const s = String(raw).trim().toLowerCase();
45
+ return s === "0" || s === "off" || s === "false" || s === "no" || s === "disable" || s === "disabled";
46
+ }
47
+ // opencode 上游身份头单一来源(legacy 与 SDK 引擎共用,free 层门禁头必须一致)。
48
+ // anonFirst 语义:authToken==="public" 时默认匿名(hermes referer);env 可显式覆盖。
49
+ export function createOpencodeHeaderBuilder({ authToken = "public", env = process.env } = {}) {
50
+ function isAnonFirst() {
51
+ const raw = env.MSLXDFF_OPENCOD_ANON_FIRST ?? env.MSLXDFF_ANON_FIRST ?? env.MSLXDFF_OPENCOD_ANON;
52
+ if (raw === undefined || raw === null || raw === "") return authToken === "public";
53
+ const s = String(raw).trim().toLowerCase();
54
+ if (s === "0" || s === "false" || s === "off" || s === "no" || s === "disable" || s === "disabled") return false;
55
+ return authToken === "public";
56
+ }
57
+ const anonFirst = isAnonFirst();
58
+ const baseHeaders = anonFirst
59
+ ? {
60
+ "Content-Type": "application/json",
61
+ Authorization: "",
62
+ "x-opencode-client": "desktop",
63
+ "User-Agent": opencodeUa(),
64
+ "HTTP-Referer": "https://hermes-agent.nousresearch.com",
65
+ "X-Title": "Hermes Agent",
66
+ }
67
+ : {
68
+ "Content-Type": "application/json",
69
+ Authorization: `Bearer ${authToken}`,
70
+ "x-opencode-client": "desktop",
71
+ };
72
+ // 无 messages 可哈希时的进程级兜底:至少 daemon 生命周期内稳定,不再每请求随机
73
+ const FALLBACK_SESSION = genId("ses_");
74
+ function buildHeaders(body, { anonymous = anonFirst, sessionId = null } = {}) {
75
+ const isStream = body?.stream !== false;
76
+ const session = sessionId || sessionFromMessages(body?.messages) || FALLBACK_SESSION;
77
+ const base = {
78
+ ...baseHeaders,
79
+ Accept: isStream ? "text/event-stream" : "*/*",
80
+ "User-Agent": opencodeUa(),
81
+ "x-opencode-session": session,
82
+ "x-opencode-request": genId("msg_"),
83
+ "x-opencode-project": "global",
84
+ };
85
+ if (anonymous) {
86
+ return { ...base, Authorization: "", "HTTP-Referer": "https://hermes-agent.nousresearch.com", "X-Title": "Hermes Agent" };
87
+ }
88
+ if (anonFirst) {
89
+ const { "HTTP-Referer": _a, "X-Title": _b, ...rest } = base;
90
+ return { ...rest, Authorization: `Bearer ${authToken}` };
91
+ }
92
+ return base;
93
+ }
94
+ return { buildHeaders, anonFirst };
95
+ }
96
+
97
+ export function createUpstreamClient({
98
+ baseUrl = process.env.UPSTREAM_BASE_URL || "https://opencode.ai",
99
+ authToken = process.env.UPSTREAM_AUTH_TOKEN || "public",
100
+ connectTimeoutMs = Number(process.env.UPSTREAM_CONNECT_TIMEOUT_MS) || 30_000,
101
+ retry = {
102
+ network: { attempts: 2, delayMs: 300 },
103
+ 429: { attempts: 1, delayMs: 100 },
104
+ 502: { attempts: 1, delayMs: 100 },
105
+ 503: { attempts: 1, delayMs: 100 },
106
+ 504: { attempts: 1, delayMs: 100 },
107
+ },
108
+ fetchImpl,
109
+ hooks,
110
+ keepAlive = true,
111
+ dispatcher,
112
+ } = {}) {
113
+ const { buildHeaders, anonFirst } = createOpencodeHeaderBuilder({ authToken });
114
+ function shouldTryAnonFree() {
115
+ const raw = process.env.MSLXDFF_FREE_ANON;
116
+ if (raw === "0" || raw === "off" || raw === "false" || raw === "no") return false;
117
+ return true;
118
+ }
119
+ function freeAnonLogFile() {
120
+ return process.env.MSLXDFF_FREE_ANON_LOG || join(process.cwd(), "free-anon-extra.txt");
121
+ }
122
+ function logFreeAnon({ model, publicStatus, anonStatus, anonAttempts, hit, totalMs }) {
123
+ const file = freeAnonLogFile();
124
+ const line = `${fmtShanghaiYMDHMS(new Date())} model=${model} public=429 anonTries=${anonAttempts} anonStatus=${anonStatus ?? "none"} hit=${hit ? "YES额外额度" : "NO无额外"} totalMs=${totalMs} count=${hit ? "1" : "0"}\n`;
125
+ try { mkdirSync(dirname(file), { recursive: true }); } catch {}
126
+ appendFile(file, line).catch(() => {});
127
+ try { if (process.env.MSLXDFF_DEBUG === "1") console.log(`[free-anon] ${line.trim()}`); } catch {}
128
+ }
129
+ let consecutiveHits = 0;
130
+
131
+ // 深模块:网络层委托 StreamingTransport
132
+ const transport = createTransport({
133
+ keepAlive,
134
+ dispatcher,
135
+ fetchImpl,
136
+ timeoutMs: connectTimeoutMs,
137
+ retry,
138
+ hooks,
139
+ });
140
+
141
+ async function chat(body, opts = {}) {
142
+ const isResp = isResponsesModel(body?.model);
143
+ // responses 路径的 reqBody 无 messages,会话哈希必须基于原始 chat body 计算
144
+ const sessionId = opts?.sessionId || sessionFromMessages(body?.messages) || null;
145
+ const url = isResp ? `${baseUrl}/zen/v1/responses` : `${baseUrl}/zen/v1/chat/completions`;
146
+ const reqBody = isResp ? chatToResponsesBody(body) : body;
147
+ const clientWantsStream = body?.stream !== false;
148
+ // zen 免费层 agent 形状门禁(2026-09-18):必须流式 + 含核心五工具名,否则 403 FreeTierError
149
+ const freeLane = authToken === "public" && isFreeModel(body?.model);
150
+ if (freeLane) ensureFreeLaneShape(reqBody, { responses: isResp });
151
+ const laneDebug = freeLane && envInt("MSLXDFF_FREE_LANE_DEBUG", 0) === 1;
152
+ if (laneDebug) {
153
+ try { console.error(`[free-lane] send model=${body?.model} url=${url} stream=${reqBody.stream} tools=${(reqBody.tools || []).length} ua=${opencodeUa()} want=${clientWantsStream}`); } catch {}
154
+ }
155
+ // 非流式调用者实际拿到的是被强制流式的上游 → 聚合回 chat completion JSON
156
+ async function agentJson(resp) {
157
+ if (!(freeLane && !clientWantsStream && resp.ok)) return resp;
158
+ const ct = resp.headers?.get?.("content-type") || "";
159
+ if (!ct.includes("text/event-stream")) return resp;
160
+ try {
161
+ const out = await aggregateChatSse(resp);
162
+ out._t = resp._t;
163
+ return out;
164
+ } catch {
165
+ return resp;
166
+ }
167
+ }
168
+ const t0 = performance.now();
169
+
170
+ // 首发请求(transport 已处理 network/429 等重试)
171
+ let res;
172
+ const firstHeaders = buildHeaders(reqBody, { sessionId });
173
+ try {
174
+ res = await transport.request({
175
+ url,
176
+ method: "POST",
177
+ headers: firstHeaders,
178
+ body: reqBody,
179
+ stream: reqBody.stream !== false,
180
+ timeoutMs: connectTimeoutMs,
181
+ retry,
182
+ });
183
+ } catch (e) {
184
+ e._t = e._t || { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - t0) };
185
+ throw e;
186
+ }
187
+ if (laneDebug) {
188
+ try { console.error(`[free-lane] resp model=${body?.model} status=${res.status} ct=${res.headers?.get?.("content-type") || ""}`); } catch {}
189
+ }
190
+
191
+ // 请求内换出口(v6 池):撞 429 时在同一请求里换源地址重发。
192
+ //
193
+ // ⚠ 顺序契约(用户 2026-10-19 明确):**先借人,再换出口**。
194
+ // 429 ⇒ ① 先走组员/组长接力(用**对方的 IPv4 出口**);② 全都没得用/都失败,
195
+ // 才轮到 WARP / IPv6 换出口拿"新的自然额度"。WARP/IPv6 是另一层兜底,不该抢在借人之前。
196
+ // 故本功能**默认关闭**,需要时才显式开:MSLXDFF_V6_INREQUEST_ROTATE=1(原 ADR-0061 行为)。
197
+ // 关闭时 429 原样交回上层 → chat-pipeline 去试组员/组长,符合用户要的顺序。
198
+ // 边界(探测实证过的硬约束):① 仅头部即 429 才换(不碰半开的 200 流);② 429 错误体已被
199
+ // transport 非流分支读尽,无连接泄漏;③ 每轮换的是 session 粘的那个槽(swap 原地换 addr/e.agent,
200
+ // 粘性映射不失效);④ 次数上限防 429 风暴空烧认领地址;⑤ 无 rotateFor 的 dispatcher(直连/WARP)
201
+ // 条件短路——WARP 换出口=重注册设备 6s~600s,在请求内做只会白等,**设计上排除**。
202
+ const inRequestRotateOn = (() => {
203
+ const raw = process.env.MSLXDFF_V6_INREQUEST_ROTATE;
204
+ if (raw === undefined || raw === null || raw === "") return false; // 缺省关:先借人
205
+ const s = String(raw).trim().toLowerCase();
206
+ return !(s === "0" || s === "false" || s === "off" || s === "no");
207
+ })();
208
+ if (inRequestRotateOn && freeLane && res.status === 429 && typeof transport.dispatcher?.rotateFor === "function") {
209
+ const maxRot = envIntOrZero("MSLXDFF_V6_INREQUEST_RETRIES", 3); // 0 = 关掉请求内换出口
210
+ let rotated = 0;
211
+ while (res.status === 429 && rotated < maxRot) {
212
+ let next = null;
213
+ try {
214
+ // 用**线上传的那个** session 头定位粘性槽(无 messages 时 buildHeaders 兜底成进程级
215
+ // FALLBACK_SESSION;传 null 会让 selector 轮转到别的槽,换掉一个没准真没耗尽的健康出口)
216
+ next = await transport.dispatcher.rotateFor(firstHeaders["x-opencode-session"] || "");
217
+ } catch { break; }
218
+ if (!next?.agent) break; // 换不到新出口(空池/全在换址/认领失败)⇒ 正常收场,不黑洞
219
+ rotated += 1;
220
+ if (laneDebug) {
221
+ try { console.error(`[free-lane] v6 in-request rotate #${rotated} ${next.from} -> ${next.addr} model=${body?.model}`); } catch {}
222
+ }
223
+ try {
224
+ res = await transport.request({
225
+ url,
226
+ method: "POST",
227
+ headers: buildHeaders(reqBody, { sessionId }),
228
+ body: reqBody,
229
+ stream: reqBody.stream !== false,
230
+ timeoutMs: connectTimeoutMs,
231
+ retry,
232
+ dispatcher: next.agent, // per-call 出口:绕开 extDispatcher 的旧槽选择
233
+ });
234
+ } catch (e) {
235
+ e._t = e._t || { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - t0) };
236
+ throw e;
237
+ }
238
+ }
239
+ if (rotated) {
240
+ res._t = { ...(res._t || {}), egressRotations: rotated, egressRecovered: res.status < 400 };
241
+ try {
242
+ appendFile(freeAnonLogFile(), `${fmtShanghaiYMDHMS(new Date())} model=${body?.model} v6-in-request-rotations=${rotated} finalStatus=${res.status}\n`).catch(() => {});
243
+ } catch {}
244
+ }
245
+ }
246
+
247
+ // 匿名兜底:仅非 anonFirst 时,public 429 + free 模型才走 hermes 空头重试
248
+ if (!anonFirst && res.status === 429 && isFreeModel(body?.model) && shouldTryAnonFree()) {
249
+ const anonRetries = envInt("MSLXDFF_FREE_ANON_RETRIES", 3);
250
+ const anonDelay = envInt("MSLXDFF_FREE_ANON_DELAY_MS", 1000);
251
+ let anonRes = null;
252
+ for (let i = 0; i < anonRetries; i++) {
253
+ await new Promise((r) => setTimeout(r, anonDelay));
254
+ try {
255
+ anonRes = await transport.request({
256
+ url,
257
+ method: "POST",
258
+ headers: buildHeaders(reqBody, { anonymous: true, sessionId }),
259
+ body: reqBody,
260
+ stream: reqBody.stream !== false,
261
+ timeoutMs: connectTimeoutMs,
262
+ retry,
263
+ });
264
+ } catch (e) {
265
+ continue;
266
+ }
267
+ if (anonRes.status !== 429) {
268
+ // responses 模型需转回 chat 形状(复用 upstream-responses)
269
+ let outAnon = anonRes;
270
+ if (isResp && anonRes.ok) {
271
+ const ctAnon = anonRes.headers.get("content-type") || "";
272
+ const isStreamAnon = (clientWantsStream || freeLane) && ctAnon.includes("text/event-stream");
273
+ if (isStreamAnon) {
274
+ outAnon = reshapeResponsesSse(anonRes, body.model);
275
+ } else {
276
+ try {
277
+ const txt = await anonRes.text();
278
+ const j = JSON.parse(txt);
279
+ if (j && Array.isArray(j.output)) {
280
+ outAnon = toChatResponse(anonRes, j);
281
+ } else {
282
+ outAnon = new Response(txt, { status: anonRes.status, headers: anonRes.headers });
283
+ }
284
+ } catch { outAnon = anonRes; }
285
+ }
286
+ }
287
+ outAnon._t = { ...(outAnon._t || {}), anonTried: true, anonAttempts: i + 1, totalMs: Math.round(performance.now() - t0) };
288
+ consecutiveHits += 1;
289
+ logFreeAnon({ model: body?.model, publicStatus: 429, anonStatus: anonRes.status, anonAttempts: i + 1, hit: true, totalMs: outAnon._t.totalMs });
290
+ if (consecutiveHits >= 2) {
291
+ try { appendFile(freeAnonLogFile(), ` -> 连续额外额度 ${consecutiveHits} 次\n`).catch(() => {}); } catch {}
292
+ }
293
+ return await agentJson(outAnon);
294
+ }
295
+ }
296
+ if (anonRes) {
297
+ anonRes._t = { ...(anonRes._t || {}), anonTried: true, anonAttempts: anonRetries, totalMs: Math.round(performance.now() - t0) };
298
+ consecutiveHits = 0;
299
+ logFreeAnon({ model: body?.model, publicStatus: 429, anonStatus: anonRes.status, anonAttempts: anonRetries, hit: false, totalMs: anonRes._t.totalMs });
300
+ return anonRes;
301
+ }
302
+ }
303
+
304
+ // responses 模型成功态转 chat(复用 upstream-responses)
305
+ if (isResp && res.ok) {
306
+ const ct = res.headers.get("content-type") || "";
307
+ const isStream = (clientWantsStream || freeLane) && ct.includes("text/event-stream");
308
+ if (isStream) {
309
+ const transformed = reshapeResponsesSse(res, body.model);
310
+ transformed._t = { ...(res._t || {}), totalMs: Math.round(performance.now() - t0) };
311
+ return await agentJson(transformed);
312
+ }
313
+ try {
314
+ const txt = await res.text();
315
+ const j = JSON.parse(txt);
316
+ if (j && Array.isArray(j.output)) {
317
+ const transformed = toChatResponse(res, j);
318
+ transformed._t = { ...(res._t || {}), totalMs: Math.round(performance.now() - t0) };
319
+ return transformed;
320
+ }
321
+ const fallback = new Response(txt, { status: res.status, headers: res.headers });
322
+ fallback._t = { ...(res._t || {}), totalMs: Math.round(performance.now() - t0) };
323
+ return fallback;
324
+ } catch {
325
+ res._t = { ...(res._t || {}), totalMs: Math.round(performance.now() - t0) };
326
+ return res;
327
+ }
328
+ }
329
+ res._t = { ...(res._t || {}), totalMs: res._t?.totalMs ?? Math.round(performance.now() - t0) };
330
+ return await agentJson(res);
331
+ }
332
+
333
+ async function preheat() {
334
+ if (isPreheatDisabled()) return { ok: false, skipped: true, reason: "disabled" };
335
+ const t0 = performance.now();
336
+ try {
337
+ const res = await transport.request({
338
+ url: `${baseUrl}/zen/v1/models`,
339
+ method: "GET",
340
+ headers: buildHeaders({ stream: false }),
341
+ stream: false,
342
+ timeoutMs: 3000,
343
+ });
344
+ try { await res.text().catch(() => {}); } catch {}
345
+ return { ok: res.ok, status: res.status, ms: Math.round(performance.now() - t0) };
346
+ } catch (e) {
347
+ return { ok: false, error: String(e?.message || e), ms: Math.round(performance.now() - t0) };
348
+ }
349
+ }
350
+ async function close() {
351
+ await transport.close();
352
+ }
353
+ const headers = buildHeaders({});
354
+ return { chat, preheat, close, headers, buildHeaders, get dispatcher() { return transport.dispatcher; }, get agent() { return transport.agent; }, [Symbol.asyncDispose]: close };
355
+ }