mslxdff 0.1.45 → 0.1.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +162 -162
- package/bin/mslxdff.js +202 -21
- package/docs/adr/0001-reasoning-content-injection.md +13 -13
- package/docs/adr/0002-models-free-filter.md +11 -11
- package/docs/adr/0003-zero-state-no-auth.md +9 -9
- package/docs/adr/0004-bearer-token.md +17 -17
- package/docs/agents/domain.md +50 -50
- package/docs/agents/issue-tracker.md +29 -29
- package/docs/agents/triage-labels.md +14 -14
- package/package.json +1 -1
- package/src/auto.js +20 -2
- package/src/chooser.js +12 -6
- package/src/daemon.js +84 -84
- package/src/logs.js +41 -2
- package/src/models.js +127 -127
- package/src/reasoning.js +32 -32
- package/src/routes/chat/broadband-handler.js +88 -0
- package/src/routes/chat/exhausted-handler.js +41 -0
- package/src/routes/chat/hedge-handler.js +142 -0
- package/src/routes/chat/index.js +178 -0
- package/src/routes/chat/local-handler.js +88 -0
- package/src/routes/chat/peer-handler.js +58 -0
- package/src/routes/chat.js +3 -342
- package/src/routes/hedge.js +251 -0
- package/src/routes/peers.js +49 -15
- package/src/server.js +57 -57
- package/src/state.js +285 -152
- package/src/sync-workbuddy.js +104 -0
- package/src/upstream.js +311 -212
package/src/upstream.js
CHANGED
|
@@ -1,213 +1,312 @@
|
|
|
1
|
-
import crypto from "node:crypto";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (
|
|
150
|
-
|
|
151
|
-
if (
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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 os from "node:os";
|
|
6
|
+
import { isFreeModel } from "./models.js";
|
|
7
|
+
import { defaultStateFile } from "./state.js";
|
|
8
|
+
|
|
9
|
+
let UndiciAgent = null;
|
|
10
|
+
let UndiciFetch = null;
|
|
11
|
+
try {
|
|
12
|
+
const mod = await import("undici");
|
|
13
|
+
UndiciAgent = mod.Agent;
|
|
14
|
+
UndiciFetch = mod.fetch;
|
|
15
|
+
} catch {
|
|
16
|
+
// undici not installed — fallback to no dispatcher (still works, just no keep-alive tuning)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function genId(prefix) {
|
|
20
|
+
return `${prefix}${crypto.randomUUID().replace(/-/g, "")}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function envInt(name, fallback) {
|
|
24
|
+
const v = Number(process.env[name]);
|
|
25
|
+
return Number.isInteger(v) && v > 0 ? v : fallback;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isPreheatDisabled() {
|
|
29
|
+
const raw = process.env.MSLXDFF_PREHEAT;
|
|
30
|
+
if (raw === undefined || raw === null || raw === "") return false;
|
|
31
|
+
const s = String(raw).trim().toLowerCase();
|
|
32
|
+
return s === "0" || s === "off" || s === "false" || s === "no" || s === "disable" || s === "disabled";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createUpstreamClient({
|
|
36
|
+
baseUrl = process.env.UPSTREAM_BASE_URL || "https://opencode.ai",
|
|
37
|
+
authToken = process.env.UPSTREAM_AUTH_TOKEN || "public",
|
|
38
|
+
connectTimeoutMs = Number(process.env.UPSTREAM_CONNECT_TIMEOUT_MS) || 30_000,
|
|
39
|
+
retry = {
|
|
40
|
+
network: { attempts: 2, delayMs: 300 },
|
|
41
|
+
429: { attempts: 1, delayMs: 100 },
|
|
42
|
+
502: { attempts: 1, delayMs: 100 },
|
|
43
|
+
503: { attempts: 1, delayMs: 100 },
|
|
44
|
+
504: { attempts: 1, delayMs: 100 },
|
|
45
|
+
},
|
|
46
|
+
fetchImpl,
|
|
47
|
+
hooks,
|
|
48
|
+
} = {}) {
|
|
49
|
+
// 使用 undici 的 fetch 与 Agent 配对,避免 global fetch 与 npm undici Agent 不兼容
|
|
50
|
+
if (!fetchImpl) fetchImpl = UndiciFetch || fetch;
|
|
51
|
+
// hooks: async (name, ctx) => ({ value, changed }) | null — 由 bin 用 runHook 绑定;错误已在上层隔离
|
|
52
|
+
const applyHook = async (name, ctx) => {
|
|
53
|
+
if (!hooks) return null;
|
|
54
|
+
try {
|
|
55
|
+
return await hooks(name, ctx);
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const baseHeaders = {
|
|
61
|
+
"Content-Type": "application/json",
|
|
62
|
+
"Authorization": `Bearer ${authToken}`,
|
|
63
|
+
"x-opencode-client": "desktop",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
function buildHeaders(body, { anonymous = false } = {}) {
|
|
67
|
+
const isStream = body?.stream !== false;
|
|
68
|
+
const base = {
|
|
69
|
+
...baseHeaders,
|
|
70
|
+
"Accept": isStream ? "text/event-stream" : "*/*",
|
|
71
|
+
"User-Agent": "opencode",
|
|
72
|
+
"x-opencode-session": genId("ses_"),
|
|
73
|
+
"x-opencode-request": genId("msg_"),
|
|
74
|
+
"x-opencode-project": "global",
|
|
75
|
+
};
|
|
76
|
+
if (anonymous) {
|
|
77
|
+
// hermes 的 free 匿名通道:空 Authorization,保留 opencode UA 以兼容 big-pickle/mimo 的 UA 门禁
|
|
78
|
+
return {
|
|
79
|
+
...base,
|
|
80
|
+
"Authorization": "",
|
|
81
|
+
"HTTP-Referer": "https://hermes-agent.nousresearch.com",
|
|
82
|
+
"X-Title": "Hermes Agent",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return base;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function shouldTryAnonFree() {
|
|
89
|
+
const raw = process.env.MSLXDFF_FREE_ANON;
|
|
90
|
+
if (raw === "0" || raw === "off" || raw === "false" || raw === "no") return false;
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function freeAnonLogFile() {
|
|
95
|
+
// 按用户要求:放在当前项目目录,方便一眼看到;可用 env 覆盖
|
|
96
|
+
return process.env.MSLXDFF_FREE_ANON_LOG || join(process.cwd(), "free-anon-extra.txt");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function logFreeAnon({ model, publicStatus, anonStatus, anonAttempts, hit, totalMs }) {
|
|
100
|
+
const file = freeAnonLogFile();
|
|
101
|
+
const line = `${new Date().toISOString()} model=${model} public=429 anonTries=${anonAttempts} anonStatus=${anonStatus ?? "none"} hit=${hit ? "YES额外额度" : "NO无额外"} totalMs=${totalMs} count=${hit ? "1" : "0"}\n`;
|
|
102
|
+
try {
|
|
103
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
104
|
+
} catch {}
|
|
105
|
+
appendFile(file, line).catch(() => {});
|
|
106
|
+
// 同时在控制台留痕(daemon 日志也能看到)
|
|
107
|
+
try {
|
|
108
|
+
if (process.env.MSLXDFF_DEBUG === "1") console.log(`[free-anon] ${line.trim()}`);
|
|
109
|
+
} catch {}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 连续命中计数(内存,方便 txt 里看出“连续几次都有”)
|
|
113
|
+
let consecutiveHits = 0;
|
|
114
|
+
|
|
115
|
+
// Keep-Alive Agent(显式复用 TCP+TLS)
|
|
116
|
+
const keepAliveTimeout = envInt("MSLXDFF_UPSTREAM_KEEPALIVE_TIMEOUT", 30_000);
|
|
117
|
+
const keepAliveMaxTimeout = envInt("MSLXDFF_UPSTREAM_KEEPALIVE_MAX_TIMEOUT", 60_000);
|
|
118
|
+
const keepAliveConnections = envInt("MSLXDFF_UPSTREAM_KEEPALIVE_CONNECTIONS", 20);
|
|
119
|
+
let dispatcher = null;
|
|
120
|
+
let agent = null;
|
|
121
|
+
if (UndiciAgent) {
|
|
122
|
+
try {
|
|
123
|
+
agent = new UndiciAgent({
|
|
124
|
+
keepAliveTimeout,
|
|
125
|
+
keepAliveMaxTimeout,
|
|
126
|
+
connections: keepAliveConnections,
|
|
127
|
+
pipelining: 1,
|
|
128
|
+
});
|
|
129
|
+
dispatcher = agent;
|
|
130
|
+
} catch {
|
|
131
|
+
agent = null;
|
|
132
|
+
dispatcher = null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function chat(body) {
|
|
137
|
+
const url = `${baseUrl}/zen/v1/chat/completions`;
|
|
138
|
+
const t0 = performance.now();
|
|
139
|
+
const attempts = [];
|
|
140
|
+
let waitMs = 0;
|
|
141
|
+
for (let attempt = 0; ; attempt++) {
|
|
142
|
+
const t = performance.now();
|
|
143
|
+
const result = await attemptOnce(url, body);
|
|
144
|
+
attempts.push({
|
|
145
|
+
attempt,
|
|
146
|
+
type: result instanceof Error ? "network" : `http${result.status}`,
|
|
147
|
+
ms: Math.round(performance.now() - t),
|
|
148
|
+
});
|
|
149
|
+
if (result instanceof Error) {
|
|
150
|
+
const entry = retry?.network;
|
|
151
|
+
if (entry && attempt < entry.attempts) {
|
|
152
|
+
await sleep(entry.delayMs);
|
|
153
|
+
waitMs += entry.delayMs;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
result._t = {
|
|
157
|
+
attempts,
|
|
158
|
+
waitMs,
|
|
159
|
+
totalMs: Math.round(performance.now() - t0),
|
|
160
|
+
};
|
|
161
|
+
throw result;
|
|
162
|
+
}
|
|
163
|
+
const entry = retry?.[result.status];
|
|
164
|
+
if (entry && attempt < entry.attempts) {
|
|
165
|
+
await sleep(entry.delayMs);
|
|
166
|
+
waitMs += entry.delayMs;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
// 额外额度探测:public 429 且为 free 模型时,用空头(hermes 方式)每秒重试 3 次
|
|
170
|
+
if (result.status === 429 && isFreeModel(body?.model) && shouldTryAnonFree()) {
|
|
171
|
+
const anonRetries = envInt("MSLXDFF_FREE_ANON_RETRIES", 3);
|
|
172
|
+
const anonDelay = envInt("MSLXDFF_FREE_ANON_DELAY_MS", 1000);
|
|
173
|
+
let anonResult = null;
|
|
174
|
+
let hit = false;
|
|
175
|
+
let hitAt = -1;
|
|
176
|
+
for (let i = 0; i < anonRetries; i++) {
|
|
177
|
+
await sleep(anonDelay);
|
|
178
|
+
waitMs += anonDelay;
|
|
179
|
+
const t2 = performance.now();
|
|
180
|
+
anonResult = await attemptOnce(url, body, { anonymous: true });
|
|
181
|
+
attempts.push({
|
|
182
|
+
attempt: `anon-${i}`,
|
|
183
|
+
type: anonResult instanceof Error ? "network" : `http${anonResult.status}`,
|
|
184
|
+
ms: Math.round(performance.now() - t2),
|
|
185
|
+
});
|
|
186
|
+
if (anonResult instanceof Error) continue;
|
|
187
|
+
if (anonResult.status !== 429) {
|
|
188
|
+
anonResult._t = {
|
|
189
|
+
attempts,
|
|
190
|
+
waitMs,
|
|
191
|
+
totalMs: Math.round(performance.now() - t0),
|
|
192
|
+
anonTried: true,
|
|
193
|
+
anonAttempts: i + 1,
|
|
194
|
+
};
|
|
195
|
+
hit = true;
|
|
196
|
+
hitAt = i + 1;
|
|
197
|
+
consecutiveHits += 1;
|
|
198
|
+
logFreeAnon({ model: body?.model, publicStatus: 429, anonStatus: anonResult.status, anonAttempts: i + 1, hit: true, totalMs: anonResult._t.totalMs });
|
|
199
|
+
// 额外在 txt 里强调连续命中
|
|
200
|
+
try {
|
|
201
|
+
if (consecutiveHits >= 2) {
|
|
202
|
+
const file = freeAnonLogFile();
|
|
203
|
+
appendFile(file, ` -> 连续额外额度 ${consecutiveHits} 次\n`).catch(() => {});
|
|
204
|
+
}
|
|
205
|
+
} catch {}
|
|
206
|
+
if (process.env.MSLXDFF_DEBUG === "1") try { console.log(`[free-anon] ${body?.model} public 429 -> anon ${anonResult.status} after ${i + 1} try HIT`); } catch {}
|
|
207
|
+
return anonResult;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// 3 次 anon 仍 429
|
|
211
|
+
if (anonResult) {
|
|
212
|
+
anonResult._t = {
|
|
213
|
+
attempts,
|
|
214
|
+
waitMs,
|
|
215
|
+
totalMs: Math.round(performance.now() - t0),
|
|
216
|
+
anonTried: true,
|
|
217
|
+
anonAttempts: anonRetries,
|
|
218
|
+
};
|
|
219
|
+
consecutiveHits = 0;
|
|
220
|
+
logFreeAnon({ model: body?.model, publicStatus: 429, anonStatus: anonResult.status, anonAttempts: anonRetries, hit: false, totalMs: anonResult._t.totalMs });
|
|
221
|
+
if (process.env.MSLXDFF_DEBUG === "1") try { console.log(`[free-anon] ${body?.model} public 429 -> anon 429 x${anonRetries} MISS`); } catch {}
|
|
222
|
+
return anonResult;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
result._t = {
|
|
226
|
+
attempts,
|
|
227
|
+
waitMs,
|
|
228
|
+
totalMs: Math.round(performance.now() - t0),
|
|
229
|
+
};
|
|
230
|
+
return result;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function attemptOnce(url, body, { anonymous = false } = {}) {
|
|
235
|
+
const controller = new AbortController();
|
|
236
|
+
const timer = setTimeout(() =>
|
|
237
|
+
controller.abort(new Error(`upstream timed out after ${connectTimeoutMs}ms`)),
|
|
238
|
+
connectTimeoutMs
|
|
239
|
+
);
|
|
240
|
+
try {
|
|
241
|
+
let reqUrl = url;
|
|
242
|
+
let headers = buildHeaders(body, { anonymous });
|
|
243
|
+
// 插件 hook:upstream:headers — 返回 { headers } 可替换请求头
|
|
244
|
+
const hh = await applyHook("upstream:headers", { url, body, headers });
|
|
245
|
+
if (hh?.changed && hh.value?.headers) headers = hh.value.headers;
|
|
246
|
+
// 插件 hook:upstream:before-request — 返回 { url, headers } 可改目标地址/头(上游不限于 opencode)
|
|
247
|
+
const br = await applyHook("upstream:before-request", { url, method: "POST", body, headers });
|
|
248
|
+
if (br?.changed && br.value) {
|
|
249
|
+
if (typeof br.value.url === "string" && br.value.url) reqUrl = br.value.url;
|
|
250
|
+
if (br.value.headers && typeof br.value.headers === "object") headers = br.value.headers;
|
|
251
|
+
}
|
|
252
|
+
const opts = {
|
|
253
|
+
method: "POST",
|
|
254
|
+
headers,
|
|
255
|
+
body: JSON.stringify(body),
|
|
256
|
+
signal: controller.signal,
|
|
257
|
+
};
|
|
258
|
+
if (dispatcher) opts.dispatcher = dispatcher;
|
|
259
|
+
const res = await fetchImpl(reqUrl, opts);
|
|
260
|
+
return res;
|
|
261
|
+
} catch (err) {
|
|
262
|
+
return err;
|
|
263
|
+
} finally {
|
|
264
|
+
clearTimeout(timer);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function preheat() {
|
|
269
|
+
if (isPreheatDisabled()) return { ok: false, skipped: true, reason: "disabled" };
|
|
270
|
+
const url = `${baseUrl}/zen/v1/models`;
|
|
271
|
+
const controller = new AbortController();
|
|
272
|
+
const timer = setTimeout(() => controller.abort(new Error("preheat timed out after 3000ms")), 3000);
|
|
273
|
+
const t0 = performance.now();
|
|
274
|
+
try {
|
|
275
|
+
const headers = buildHeaders({ stream: false });
|
|
276
|
+
const opts = {
|
|
277
|
+
method: "GET",
|
|
278
|
+
headers,
|
|
279
|
+
signal: controller.signal,
|
|
280
|
+
};
|
|
281
|
+
if (dispatcher) opts.dispatcher = dispatcher;
|
|
282
|
+
const res = await fetchImpl(url, opts);
|
|
283
|
+
const ms = Math.round(performance.now() - t0);
|
|
284
|
+
// 消耗 body 以释放连接回池(即使不需要内容)
|
|
285
|
+
try { if (res.body) await res.text().catch(() => {}); } catch {}
|
|
286
|
+
return { ok: res.ok, status: res.status, ms };
|
|
287
|
+
} catch (err) {
|
|
288
|
+
return { ok: false, error: String(err?.message || err), ms: Math.round(performance.now() - t0) };
|
|
289
|
+
} finally {
|
|
290
|
+
clearTimeout(timer);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
let closed = false;
|
|
295
|
+
async function close() {
|
|
296
|
+
if (closed) return;
|
|
297
|
+
closed = true;
|
|
298
|
+
if (agent && typeof agent.close === "function") {
|
|
299
|
+
try { await agent.close(); } catch {}
|
|
300
|
+
} else if (dispatcher && typeof dispatcher.close === "function" && dispatcher !== agent) {
|
|
301
|
+
try { await dispatcher.close(); } catch {}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// 兼容旧调用:headers 为动态生成,暴露 getter 快照(用于测试/展示)
|
|
306
|
+
const headers = buildHeaders({});
|
|
307
|
+
return { chat, preheat, close, headers, buildHeaders, dispatcher, agent, [Symbol.asyncDispose]: close };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function sleep(ms) {
|
|
311
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
213
312
|
}
|