mslxdff 0.1.159 → 0.1.160

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 (95) hide show
  1. package/bin/mslxdff.js +4 -4
  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/auto-race.js +1 -1
  19. package/src/chat-pipeline/index.js +123 -123
  20. package/src/chat-pipeline/policy.js +76 -76
  21. package/src/chat-pipeline/serial-trial.js +210 -210
  22. package/src/cli/commands/group.js +249 -249
  23. package/src/cli/commands/model/picks.js +50 -50
  24. package/src/cli/commands/provider/bench-via.js +247 -247
  25. package/src/cli/commands/provider/bench.js +141 -141
  26. package/src/cli/commands/provider/index.js +124 -124
  27. package/src/cli/commands/provider/models.js +139 -139
  28. package/src/cli/commands/provider/qwenwork-login.js +119 -119
  29. package/src/cli/commands/stats.js +20 -5
  30. package/src/cli/commands/sync.js +232 -232
  31. package/src/cli/provider-row.js +2 -2
  32. package/src/cli/status.js +279 -279
  33. package/src/daemon.js +96 -96
  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 +225 -225
  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/keyring.js +60 -60
  42. package/src/providers/qoder/chat.js +183 -183
  43. package/src/providers/qoder/index.js +230 -230
  44. package/src/providers/qoder/sse.js +103 -103
  45. package/src/providers/qwenwork/account-store.js +133 -133
  46. package/src/providers/qwenwork/constants.js +67 -67
  47. package/src/providers/qwenwork/cosy.js +120 -120
  48. package/src/providers/qwenwork/crypto.js +218 -218
  49. package/src/providers/qwenwork/http.js +20 -20
  50. package/src/providers/qwenwork/index.js +327 -327
  51. package/src/providers/qwenwork/payload.js +142 -142
  52. package/src/providers/qwenwork/rsa.js +54 -54
  53. package/src/providers/qwenwork/sse.js +268 -268
  54. package/src/providers/qwenwork/stream.js +130 -130
  55. package/src/providers/qwenwork/upstream.js +120 -120
  56. package/src/providers/qwenwork.js +1 -1
  57. package/src/providers/registry.js +66 -66
  58. package/src/providers/workbuddy/chat.js +248 -248
  59. package/src/providers/workbuddy/reshape.js +152 -152
  60. package/src/providers/workbuddy.js +2 -2
  61. package/src/providers/zcode/chat.js +11 -10
  62. package/src/providers/zcode/const.js +1 -1
  63. package/src/providers/zcode/context-shape.js +162 -0
  64. package/src/providers/zcode/headers.js +38 -0
  65. package/src/reasoning.js +32 -32
  66. package/src/routes/chat/broadband-handler.js +3 -2
  67. package/src/routes/chat/gateway.js +46 -46
  68. package/src/routes/chat/hedge-handler.js +3 -0
  69. package/src/routes/chat/local-handler.js +2 -0
  70. package/src/routes/chat/peer-handler.js +2 -0
  71. package/src/routes/chat/relay-pipeline.js +264 -250
  72. package/src/routes/chat/via-route-handler.js +146 -144
  73. package/src/routes/hedge.js +255 -255
  74. package/src/routes/models-route.js +167 -167
  75. package/src/routes/peers.js +273 -273
  76. package/src/routes/stream-scan.js +86 -0
  77. package/src/routes/stream.js +393 -438
  78. package/src/runtime/bootstrap.js +45 -45
  79. package/src/runtime/provider-gate.js +33 -33
  80. package/src/runtime/providers-setup.js +165 -165
  81. package/src/server.js +64 -64
  82. package/src/state/schemas/allowlist.js +92 -92
  83. package/src/sync-opencode.js +280 -280
  84. package/src/transport/index.js +244 -244
  85. package/src/transport/pool.js +56 -56
  86. package/src/transport/retry.js +24 -24
  87. package/src/transport/sse.js +93 -93
  88. package/src/upstream-probe/display.js +52 -52
  89. package/src/upstream-probe/probe.js +49 -49
  90. package/src/upstream-probe/rotate.js +110 -110
  91. package/src/upstream-probe/start.js +45 -45
  92. package/src/upstream.js +289 -289
  93. package/src/usage/record.js +8 -1
  94. package/src/usage/report.js +39 -2
  95. package/docs/cli_help_mini.md +0 -133
package/src/upstream.js CHANGED
@@ -1,291 +1,291 @@
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
-
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
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
- function isPreheatDisabled() {
37
- const raw = process.env.MSLXDFF_PREHEAT;
38
- if (raw === undefined || raw === null || raw === "") return false;
39
- const s = String(raw).trim().toLowerCase();
40
- return s === "0" || s === "off" || s === "false" || s === "no" || s === "disable" || s === "disabled";
41
- }
42
- // opencode 上游身份头单一来源(legacy 与 SDK 引擎共用,free 层门禁头必须一致)。
43
- // anonFirst 语义:authToken==="public" 时默认匿名(hermes referer);env 可显式覆盖。
44
- export function createOpencodeHeaderBuilder({ authToken = "public", env = process.env } = {}) {
45
- function isAnonFirst() {
46
- const raw = env.MSLXDFF_OPENCOD_ANON_FIRST ?? env.MSLXDFF_ANON_FIRST ?? env.MSLXDFF_OPENCOD_ANON;
47
- if (raw === undefined || raw === null || raw === "") return authToken === "public";
48
- const s = String(raw).trim().toLowerCase();
49
- if (s === "0" || s === "false" || s === "off" || s === "no" || s === "disable" || s === "disabled") return false;
50
- return authToken === "public";
51
- }
52
- const anonFirst = isAnonFirst();
53
- const baseHeaders = anonFirst
54
- ? {
55
- "Content-Type": "application/json",
56
- Authorization: "",
57
- "x-opencode-client": "desktop",
58
- "User-Agent": opencodeUa(),
59
- "HTTP-Referer": "https://hermes-agent.nousresearch.com",
60
- "X-Title": "Hermes Agent",
61
- }
62
- : {
63
- "Content-Type": "application/json",
64
- Authorization: `Bearer ${authToken}`,
65
- "x-opencode-client": "desktop",
66
- };
67
- // 无 messages 可哈希时的进程级兜底:至少 daemon 生命周期内稳定,不再每请求随机
68
- const FALLBACK_SESSION = genId("ses_");
69
- function buildHeaders(body, { anonymous = anonFirst, sessionId = null } = {}) {
70
- const isStream = body?.stream !== false;
71
- const session = sessionId || sessionFromMessages(body?.messages) || FALLBACK_SESSION;
72
- const base = {
73
- ...baseHeaders,
74
- Accept: isStream ? "text/event-stream" : "*/*",
75
- "User-Agent": opencodeUa(),
76
- "x-opencode-session": session,
77
- "x-opencode-request": genId("msg_"),
78
- "x-opencode-project": "global",
79
- };
80
- if (anonymous) {
81
- return { ...base, Authorization: "", "HTTP-Referer": "https://hermes-agent.nousresearch.com", "X-Title": "Hermes Agent" };
82
- }
83
- if (anonFirst) {
84
- const { "HTTP-Referer": _a, "X-Title": _b, ...rest } = base;
85
- return { ...rest, Authorization: `Bearer ${authToken}` };
86
- }
87
- return base;
88
- }
89
- return { buildHeaders, anonFirst };
90
- }
91
-
92
- export function createUpstreamClient({
93
- baseUrl = process.env.UPSTREAM_BASE_URL || "https://opencode.ai",
94
- authToken = process.env.UPSTREAM_AUTH_TOKEN || "public",
95
- connectTimeoutMs = Number(process.env.UPSTREAM_CONNECT_TIMEOUT_MS) || 30_000,
96
- retry = {
97
- network: { attempts: 2, delayMs: 300 },
98
- 429: { attempts: 1, delayMs: 100 },
99
- 502: { attempts: 1, delayMs: 100 },
100
- 503: { attempts: 1, delayMs: 100 },
101
- 504: { attempts: 1, delayMs: 100 },
102
- },
103
- fetchImpl,
104
- hooks,
105
- keepAlive = true,
106
- } = {}) {
107
- const { buildHeaders, anonFirst } = createOpencodeHeaderBuilder({ authToken });
108
- function shouldTryAnonFree() {
109
- const raw = process.env.MSLXDFF_FREE_ANON;
110
- if (raw === "0" || raw === "off" || raw === "false" || raw === "no") return false;
111
- return true;
112
- }
113
- function freeAnonLogFile() {
114
- return process.env.MSLXDFF_FREE_ANON_LOG || join(process.cwd(), "free-anon-extra.txt");
115
- }
116
- function logFreeAnon({ model, publicStatus, anonStatus, anonAttempts, hit, totalMs }) {
117
- const file = freeAnonLogFile();
118
- 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`;
119
- try { mkdirSync(dirname(file), { recursive: true }); } catch {}
120
- appendFile(file, line).catch(() => {});
121
- try { if (process.env.MSLXDFF_DEBUG === "1") console.log(`[free-anon] ${line.trim()}`); } catch {}
122
- }
123
- let consecutiveHits = 0;
124
-
125
- // 深模块:网络层委托 StreamingTransport
126
- const transport = createTransport({
127
- keepAlive,
128
- fetchImpl,
129
- timeoutMs: connectTimeoutMs,
130
- retry,
131
- hooks,
132
- });
133
-
134
- async function chat(body, opts = {}) {
135
- const isResp = isResponsesModel(body?.model);
136
- // responses 路径的 reqBody 无 messages,会话哈希必须基于原始 chat body 计算
137
- const sessionId = opts?.sessionId || sessionFromMessages(body?.messages) || null;
138
- const url = isResp ? `${baseUrl}/zen/v1/responses` : `${baseUrl}/zen/v1/chat/completions`;
139
- const reqBody = isResp ? chatToResponsesBody(body) : body;
140
- const clientWantsStream = body?.stream !== false;
141
- // zen 免费层 agent 形状门禁(2026-09-18):必须流式 + 含核心五工具名,否则 403 FreeTierError
142
- const freeLane = authToken === "public" && isFreeModel(body?.model);
143
- if (freeLane) ensureFreeLaneShape(reqBody, { responses: isResp });
144
- const laneDebug = freeLane && envInt("MSLXDFF_FREE_LANE_DEBUG", 0) === 1;
145
- if (laneDebug) {
146
- try { console.error(`[free-lane] send model=${body?.model} url=${url} stream=${reqBody.stream} tools=${(reqBody.tools || []).length} ua=${opencodeUa()} want=${clientWantsStream}`); } catch {}
147
- }
148
- // 非流式调用者实际拿到的是被强制流式的上游 → 聚合回 chat completion JSON
149
- async function agentJson(resp) {
150
- if (!(freeLane && !clientWantsStream && resp.ok)) return resp;
151
- const ct = resp.headers?.get?.("content-type") || "";
152
- if (!ct.includes("text/event-stream")) return resp;
153
- try {
154
- const out = await aggregateChatSse(resp);
155
- out._t = resp._t;
156
- return out;
157
- } catch {
158
- return resp;
159
- }
160
- }
161
- const t0 = performance.now();
162
-
163
- // 首发请求(transport 已处理 network/429 等重试)
164
- let res;
165
- try {
166
- res = await transport.request({
167
- url,
168
- method: "POST",
169
- headers: buildHeaders(reqBody, { sessionId }),
170
- body: reqBody,
171
- stream: reqBody.stream !== false,
172
- timeoutMs: connectTimeoutMs,
173
- retry,
174
- });
175
- } catch (e) {
176
- e._t = e._t || { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - t0) };
177
- throw e;
178
- }
179
- if (laneDebug) {
180
- try { console.error(`[free-lane] resp model=${body?.model} status=${res.status} ct=${res.headers?.get?.("content-type") || ""}`); } catch {}
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
+ function isPreheatDisabled() {
37
+ const raw = process.env.MSLXDFF_PREHEAT;
38
+ if (raw === undefined || raw === null || raw === "") return false;
39
+ const s = String(raw).trim().toLowerCase();
40
+ return s === "0" || s === "off" || s === "false" || s === "no" || s === "disable" || s === "disabled";
41
+ }
42
+ // opencode 上游身份头单一来源(legacy 与 SDK 引擎共用,free 层门禁头必须一致)。
43
+ // anonFirst 语义:authToken==="public" 时默认匿名(hermes referer);env 可显式覆盖。
44
+ export function createOpencodeHeaderBuilder({ authToken = "public", env = process.env } = {}) {
45
+ function isAnonFirst() {
46
+ const raw = env.MSLXDFF_OPENCOD_ANON_FIRST ?? env.MSLXDFF_ANON_FIRST ?? env.MSLXDFF_OPENCOD_ANON;
47
+ if (raw === undefined || raw === null || raw === "") return authToken === "public";
48
+ const s = String(raw).trim().toLowerCase();
49
+ if (s === "0" || s === "false" || s === "off" || s === "no" || s === "disable" || s === "disabled") return false;
50
+ return authToken === "public";
51
+ }
52
+ const anonFirst = isAnonFirst();
53
+ const baseHeaders = anonFirst
54
+ ? {
55
+ "Content-Type": "application/json",
56
+ Authorization: "",
57
+ "x-opencode-client": "desktop",
58
+ "User-Agent": opencodeUa(),
59
+ "HTTP-Referer": "https://hermes-agent.nousresearch.com",
60
+ "X-Title": "Hermes Agent",
61
+ }
62
+ : {
63
+ "Content-Type": "application/json",
64
+ Authorization: `Bearer ${authToken}`,
65
+ "x-opencode-client": "desktop",
66
+ };
67
+ // 无 messages 可哈希时的进程级兜底:至少 daemon 生命周期内稳定,不再每请求随机
68
+ const FALLBACK_SESSION = genId("ses_");
69
+ function buildHeaders(body, { anonymous = anonFirst, sessionId = null } = {}) {
70
+ const isStream = body?.stream !== false;
71
+ const session = sessionId || sessionFromMessages(body?.messages) || FALLBACK_SESSION;
72
+ const base = {
73
+ ...baseHeaders,
74
+ Accept: isStream ? "text/event-stream" : "*/*",
75
+ "User-Agent": opencodeUa(),
76
+ "x-opencode-session": session,
77
+ "x-opencode-request": genId("msg_"),
78
+ "x-opencode-project": "global",
79
+ };
80
+ if (anonymous) {
81
+ return { ...base, Authorization: "", "HTTP-Referer": "https://hermes-agent.nousresearch.com", "X-Title": "Hermes Agent" };
181
82
  }
182
-
183
- // 匿名兜底:仅非 anonFirst 时,public 429 + free 模型才走 hermes 空头重试
184
- if (!anonFirst && res.status === 429 && isFreeModel(body?.model) && shouldTryAnonFree()) {
185
- const anonRetries = envInt("MSLXDFF_FREE_ANON_RETRIES", 3);
186
- const anonDelay = envInt("MSLXDFF_FREE_ANON_DELAY_MS", 1000);
187
- let anonRes = null;
188
- for (let i = 0; i < anonRetries; i++) {
189
- await new Promise((r) => setTimeout(r, anonDelay));
190
- try {
191
- anonRes = await transport.request({
192
- url,
193
- method: "POST",
194
- headers: buildHeaders(reqBody, { anonymous: true, sessionId }),
195
- body: reqBody,
196
- stream: reqBody.stream !== false,
197
- timeoutMs: connectTimeoutMs,
198
- retry,
199
- });
200
- } catch (e) {
201
- continue;
202
- }
203
- if (anonRes.status !== 429) {
204
- // responses 模型需转回 chat 形状(复用 upstream-responses)
205
- let outAnon = anonRes;
206
- if (isResp && anonRes.ok) {
207
- const ctAnon = anonRes.headers.get("content-type") || "";
208
- const isStreamAnon = (clientWantsStream || freeLane) && ctAnon.includes("text/event-stream");
209
- if (isStreamAnon) {
210
- outAnon = reshapeResponsesSse(anonRes, body.model);
211
- } else {
212
- try {
213
- const txt = await anonRes.text();
214
- const j = JSON.parse(txt);
215
- if (j && Array.isArray(j.output)) {
216
- outAnon = toChatResponse(anonRes, j);
217
- } else {
218
- outAnon = new Response(txt, { status: anonRes.status, headers: anonRes.headers });
219
- }
220
- } catch { outAnon = anonRes; }
221
- }
222
- }
223
- outAnon._t = { ...(outAnon._t || {}), anonTried: true, anonAttempts: i + 1, totalMs: Math.round(performance.now() - t0) };
224
- consecutiveHits += 1;
225
- logFreeAnon({ model: body?.model, publicStatus: 429, anonStatus: anonRes.status, anonAttempts: i + 1, hit: true, totalMs: outAnon._t.totalMs });
226
- if (consecutiveHits >= 2) {
227
- try { appendFile(freeAnonLogFile(), ` -> 连续额外额度 ${consecutiveHits} 次\n`).catch(() => {}); } catch {}
228
- }
229
- return await agentJson(outAnon);
230
- }
231
- }
232
- if (anonRes) {
233
- anonRes._t = { ...(anonRes._t || {}), anonTried: true, anonAttempts: anonRetries, totalMs: Math.round(performance.now() - t0) };
234
- consecutiveHits = 0;
235
- logFreeAnon({ model: body?.model, publicStatus: 429, anonStatus: anonRes.status, anonAttempts: anonRetries, hit: false, totalMs: anonRes._t.totalMs });
236
- return anonRes;
237
- }
238
- }
239
-
240
- // responses 模型成功态转 chat(复用 upstream-responses)
241
- if (isResp && res.ok) {
242
- const ct = res.headers.get("content-type") || "";
243
- const isStream = (clientWantsStream || freeLane) && ct.includes("text/event-stream");
244
- if (isStream) {
245
- const transformed = reshapeResponsesSse(res, body.model);
246
- transformed._t = { ...(res._t || {}), totalMs: Math.round(performance.now() - t0) };
247
- return await agentJson(transformed);
248
- }
249
- try {
250
- const txt = await res.text();
251
- const j = JSON.parse(txt);
252
- if (j && Array.isArray(j.output)) {
253
- const transformed = toChatResponse(res, j);
254
- transformed._t = { ...(res._t || {}), totalMs: Math.round(performance.now() - t0) };
255
- return transformed;
256
- }
257
- const fallback = new Response(txt, { status: res.status, headers: res.headers });
258
- fallback._t = { ...(res._t || {}), totalMs: Math.round(performance.now() - t0) };
259
- return fallback;
260
- } catch {
261
- res._t = { ...(res._t || {}), totalMs: Math.round(performance.now() - t0) };
262
- return res;
263
- }
264
- }
265
- res._t = { ...(res._t || {}), totalMs: res._t?.totalMs ?? Math.round(performance.now() - t0) };
266
- return await agentJson(res);
267
- }
268
-
269
- async function preheat() {
270
- if (isPreheatDisabled()) return { ok: false, skipped: true, reason: "disabled" };
271
- const t0 = performance.now();
272
- try {
273
- const res = await transport.request({
274
- url: `${baseUrl}/zen/v1/models`,
275
- method: "GET",
276
- headers: buildHeaders({ stream: false }),
277
- stream: false,
278
- timeoutMs: 3000,
279
- });
280
- try { await res.text().catch(() => {}); } catch {}
281
- return { ok: res.ok, status: res.status, ms: Math.round(performance.now() - t0) };
282
- } catch (e) {
283
- return { ok: false, error: String(e?.message || e), ms: Math.round(performance.now() - t0) };
284
- }
285
- }
286
- async function close() {
287
- await transport.close();
288
- }
289
- const headers = buildHeaders({});
290
- return { chat, preheat, close, headers, buildHeaders, get dispatcher() { return transport.dispatcher; }, get agent() { return transport.agent; }, [Symbol.asyncDispose]: close };
291
- }
83
+ if (anonFirst) {
84
+ const { "HTTP-Referer": _a, "X-Title": _b, ...rest } = base;
85
+ return { ...rest, Authorization: `Bearer ${authToken}` };
86
+ }
87
+ return base;
88
+ }
89
+ return { buildHeaders, anonFirst };
90
+ }
91
+
92
+ export function createUpstreamClient({
93
+ baseUrl = process.env.UPSTREAM_BASE_URL || "https://opencode.ai",
94
+ authToken = process.env.UPSTREAM_AUTH_TOKEN || "public",
95
+ connectTimeoutMs = Number(process.env.UPSTREAM_CONNECT_TIMEOUT_MS) || 30_000,
96
+ retry = {
97
+ network: { attempts: 2, delayMs: 300 },
98
+ 429: { attempts: 1, delayMs: 100 },
99
+ 502: { attempts: 1, delayMs: 100 },
100
+ 503: { attempts: 1, delayMs: 100 },
101
+ 504: { attempts: 1, delayMs: 100 },
102
+ },
103
+ fetchImpl,
104
+ hooks,
105
+ keepAlive = true,
106
+ } = {}) {
107
+ const { buildHeaders, anonFirst } = createOpencodeHeaderBuilder({ authToken });
108
+ function shouldTryAnonFree() {
109
+ const raw = process.env.MSLXDFF_FREE_ANON;
110
+ if (raw === "0" || raw === "off" || raw === "false" || raw === "no") return false;
111
+ return true;
112
+ }
113
+ function freeAnonLogFile() {
114
+ return process.env.MSLXDFF_FREE_ANON_LOG || join(process.cwd(), "free-anon-extra.txt");
115
+ }
116
+ function logFreeAnon({ model, publicStatus, anonStatus, anonAttempts, hit, totalMs }) {
117
+ const file = freeAnonLogFile();
118
+ 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`;
119
+ try { mkdirSync(dirname(file), { recursive: true }); } catch {}
120
+ appendFile(file, line).catch(() => {});
121
+ try { if (process.env.MSLXDFF_DEBUG === "1") console.log(`[free-anon] ${line.trim()}`); } catch {}
122
+ }
123
+ let consecutiveHits = 0;
124
+
125
+ // 深模块:网络层委托 StreamingTransport
126
+ const transport = createTransport({
127
+ keepAlive,
128
+ fetchImpl,
129
+ timeoutMs: connectTimeoutMs,
130
+ retry,
131
+ hooks,
132
+ });
133
+
134
+ async function chat(body, opts = {}) {
135
+ const isResp = isResponsesModel(body?.model);
136
+ // responses 路径的 reqBody 无 messages,会话哈希必须基于原始 chat body 计算
137
+ const sessionId = opts?.sessionId || sessionFromMessages(body?.messages) || null;
138
+ const url = isResp ? `${baseUrl}/zen/v1/responses` : `${baseUrl}/zen/v1/chat/completions`;
139
+ const reqBody = isResp ? chatToResponsesBody(body) : body;
140
+ const clientWantsStream = body?.stream !== false;
141
+ // zen 免费层 agent 形状门禁(2026-09-18):必须流式 + 含核心五工具名,否则 403 FreeTierError
142
+ const freeLane = authToken === "public" && isFreeModel(body?.model);
143
+ if (freeLane) ensureFreeLaneShape(reqBody, { responses: isResp });
144
+ const laneDebug = freeLane && envInt("MSLXDFF_FREE_LANE_DEBUG", 0) === 1;
145
+ if (laneDebug) {
146
+ try { console.error(`[free-lane] send model=${body?.model} url=${url} stream=${reqBody.stream} tools=${(reqBody.tools || []).length} ua=${opencodeUa()} want=${clientWantsStream}`); } catch {}
147
+ }
148
+ // 非流式调用者实际拿到的是被强制流式的上游 → 聚合回 chat completion JSON
149
+ async function agentJson(resp) {
150
+ if (!(freeLane && !clientWantsStream && resp.ok)) return resp;
151
+ const ct = resp.headers?.get?.("content-type") || "";
152
+ if (!ct.includes("text/event-stream")) return resp;
153
+ try {
154
+ const out = await aggregateChatSse(resp);
155
+ out._t = resp._t;
156
+ return out;
157
+ } catch {
158
+ return resp;
159
+ }
160
+ }
161
+ const t0 = performance.now();
162
+
163
+ // 首发请求(transport 已处理 network/429 等重试)
164
+ let res;
165
+ try {
166
+ res = await transport.request({
167
+ url,
168
+ method: "POST",
169
+ headers: buildHeaders(reqBody, { sessionId }),
170
+ body: reqBody,
171
+ stream: reqBody.stream !== false,
172
+ timeoutMs: connectTimeoutMs,
173
+ retry,
174
+ });
175
+ } catch (e) {
176
+ e._t = e._t || { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - t0) };
177
+ throw e;
178
+ }
179
+ if (laneDebug) {
180
+ try { console.error(`[free-lane] resp model=${body?.model} status=${res.status} ct=${res.headers?.get?.("content-type") || ""}`); } catch {}
181
+ }
182
+
183
+ // 匿名兜底:仅非 anonFirst 时,public 429 + free 模型才走 hermes 空头重试
184
+ if (!anonFirst && res.status === 429 && isFreeModel(body?.model) && shouldTryAnonFree()) {
185
+ const anonRetries = envInt("MSLXDFF_FREE_ANON_RETRIES", 3);
186
+ const anonDelay = envInt("MSLXDFF_FREE_ANON_DELAY_MS", 1000);
187
+ let anonRes = null;
188
+ for (let i = 0; i < anonRetries; i++) {
189
+ await new Promise((r) => setTimeout(r, anonDelay));
190
+ try {
191
+ anonRes = await transport.request({
192
+ url,
193
+ method: "POST",
194
+ headers: buildHeaders(reqBody, { anonymous: true, sessionId }),
195
+ body: reqBody,
196
+ stream: reqBody.stream !== false,
197
+ timeoutMs: connectTimeoutMs,
198
+ retry,
199
+ });
200
+ } catch (e) {
201
+ continue;
202
+ }
203
+ if (anonRes.status !== 429) {
204
+ // responses 模型需转回 chat 形状(复用 upstream-responses)
205
+ let outAnon = anonRes;
206
+ if (isResp && anonRes.ok) {
207
+ const ctAnon = anonRes.headers.get("content-type") || "";
208
+ const isStreamAnon = (clientWantsStream || freeLane) && ctAnon.includes("text/event-stream");
209
+ if (isStreamAnon) {
210
+ outAnon = reshapeResponsesSse(anonRes, body.model);
211
+ } else {
212
+ try {
213
+ const txt = await anonRes.text();
214
+ const j = JSON.parse(txt);
215
+ if (j && Array.isArray(j.output)) {
216
+ outAnon = toChatResponse(anonRes, j);
217
+ } else {
218
+ outAnon = new Response(txt, { status: anonRes.status, headers: anonRes.headers });
219
+ }
220
+ } catch { outAnon = anonRes; }
221
+ }
222
+ }
223
+ outAnon._t = { ...(outAnon._t || {}), anonTried: true, anonAttempts: i + 1, totalMs: Math.round(performance.now() - t0) };
224
+ consecutiveHits += 1;
225
+ logFreeAnon({ model: body?.model, publicStatus: 429, anonStatus: anonRes.status, anonAttempts: i + 1, hit: true, totalMs: outAnon._t.totalMs });
226
+ if (consecutiveHits >= 2) {
227
+ try { appendFile(freeAnonLogFile(), ` -> 连续额外额度 ${consecutiveHits} 次\n`).catch(() => {}); } catch {}
228
+ }
229
+ return await agentJson(outAnon);
230
+ }
231
+ }
232
+ if (anonRes) {
233
+ anonRes._t = { ...(anonRes._t || {}), anonTried: true, anonAttempts: anonRetries, totalMs: Math.round(performance.now() - t0) };
234
+ consecutiveHits = 0;
235
+ logFreeAnon({ model: body?.model, publicStatus: 429, anonStatus: anonRes.status, anonAttempts: anonRetries, hit: false, totalMs: anonRes._t.totalMs });
236
+ return anonRes;
237
+ }
238
+ }
239
+
240
+ // responses 模型成功态转 chat(复用 upstream-responses)
241
+ if (isResp && res.ok) {
242
+ const ct = res.headers.get("content-type") || "";
243
+ const isStream = (clientWantsStream || freeLane) && ct.includes("text/event-stream");
244
+ if (isStream) {
245
+ const transformed = reshapeResponsesSse(res, body.model);
246
+ transformed._t = { ...(res._t || {}), totalMs: Math.round(performance.now() - t0) };
247
+ return await agentJson(transformed);
248
+ }
249
+ try {
250
+ const txt = await res.text();
251
+ const j = JSON.parse(txt);
252
+ if (j && Array.isArray(j.output)) {
253
+ const transformed = toChatResponse(res, j);
254
+ transformed._t = { ...(res._t || {}), totalMs: Math.round(performance.now() - t0) };
255
+ return transformed;
256
+ }
257
+ const fallback = new Response(txt, { status: res.status, headers: res.headers });
258
+ fallback._t = { ...(res._t || {}), totalMs: Math.round(performance.now() - t0) };
259
+ return fallback;
260
+ } catch {
261
+ res._t = { ...(res._t || {}), totalMs: Math.round(performance.now() - t0) };
262
+ return res;
263
+ }
264
+ }
265
+ res._t = { ...(res._t || {}), totalMs: res._t?.totalMs ?? Math.round(performance.now() - t0) };
266
+ return await agentJson(res);
267
+ }
268
+
269
+ async function preheat() {
270
+ if (isPreheatDisabled()) return { ok: false, skipped: true, reason: "disabled" };
271
+ const t0 = performance.now();
272
+ try {
273
+ const res = await transport.request({
274
+ url: `${baseUrl}/zen/v1/models`,
275
+ method: "GET",
276
+ headers: buildHeaders({ stream: false }),
277
+ stream: false,
278
+ timeoutMs: 3000,
279
+ });
280
+ try { await res.text().catch(() => {}); } catch {}
281
+ return { ok: res.ok, status: res.status, ms: Math.round(performance.now() - t0) };
282
+ } catch (e) {
283
+ return { ok: false, error: String(e?.message || e), ms: Math.round(performance.now() - t0) };
284
+ }
285
+ }
286
+ async function close() {
287
+ await transport.close();
288
+ }
289
+ const headers = buildHeaders({});
290
+ return { chat, preheat, close, headers, buildHeaders, get dispatcher() { return transport.dispatcher; }, get agent() { return transport.agent; }, [Symbol.asyncDispose]: close };
291
+ }
@@ -78,7 +78,8 @@ export async function recordUsage(entry, { dir, now = new Date() } = {}) {
78
78
 
79
79
  // 从 relay 结果组装一行 usage —— 行形状由本模块拥有,调用方只交原始字段。
80
80
  // usage 即 metrics.js 的 extractUsageFromJson 输出(prompt/completion/total/reasoning)。
81
- export function recordChatUsage({ model, via, usage, ttfbMs, totalMs, tps, interrupted } = {}) {
81
+ export function recordChatUsage({ model, via, usage, ttfbMs, totalMs, tps, interrupted, reasoningChars, stream } = {}) {
82
+ const rc = Number(reasoningChars);
82
83
  return recordUsage({
83
84
  model,
84
85
  via,
@@ -87,6 +88,12 @@ export function recordChatUsage({ model, via, usage, ttfbMs, totalMs, tps, inter
87
88
  completion_tokens: usage?.completion_tokens ?? 0,
88
89
  total_tokens: usage?.total_tokens ?? 0,
89
90
  reasoning_tokens: usage?.reasoning_tokens ?? 0,
91
+ // —— 以下为纯增字段:只存原始观测,chars/4 的估算留给聚合层(口径可演进,不必回填历史行)——
92
+ reasoning_chars: Number.isFinite(rc) && rc > 0 ? Math.trunc(rc) : 0,
93
+ // 上游是否明确上报过思考 tokens(metrics.js 只在数值有限时才写 reasoning_tokens)
94
+ reasoning_reported: Number.isFinite(usage?.reasoning_tokens) ? 1 : 0,
95
+ // 调用方没表态时整字段不落盘:「未知」≠「非流式」,覆盖度分母宁低不假高
96
+ ...(stream === undefined ? {} : { stream: stream ? 1 : 0 }),
90
97
  ttfbMs: Number.isFinite(ttfbMs) ? ttfbMs : null,
91
98
  totalMs: Number.isFinite(totalMs) ? totalMs : null,
92
99
  tps: Number.isFinite(tps) ? tps : null,