mslxdff 0.1.88 → 0.1.90

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 (46) hide show
  1. package/package.json +1 -1
  2. package/src/bench/cline-bench.js +21 -33
  3. package/src/bench/probe.js +4 -11
  4. package/src/bench/runner.js +8 -23
  5. package/src/bench/via-probe.js +12 -30
  6. package/src/bench/workbuddy-bench.js +15 -31
  7. package/src/chat/engine.js +160 -0
  8. package/src/chat/repl.js +32 -299
  9. package/src/chat/terminal.js +95 -0
  10. package/src/chat/tool-handlers.js +67 -0
  11. package/src/chat/upstream.js +1 -4
  12. package/src/chat-pipeline/auto-race.js +124 -0
  13. package/src/chat-pipeline/engine.js +17 -0
  14. package/src/chat-pipeline/index.js +86 -0
  15. package/src/chat-pipeline/planner.js +31 -0
  16. package/src/chat-pipeline/policy.js +73 -0
  17. package/src/chat-pipeline/serial-trial.js +144 -0
  18. package/src/cli/commands/model/list-providers.js +75 -0
  19. package/src/cli/commands/model/list-render.js +79 -0
  20. package/src/cli/commands/model/list.js +208 -0
  21. package/src/cli/commands/model/picks.js +45 -0
  22. package/src/cli/commands/model/stats.js +43 -0
  23. package/src/cli/commands/model/status.js +47 -0
  24. package/src/cli/commands/model.js +17 -371
  25. package/src/cli/help.js +2 -1
  26. package/src/providers/cline/chat.js +26 -53
  27. package/src/providers/workbuddy/chat.js +109 -221
  28. package/src/routes/chat/gateway.js +23 -286
  29. package/src/routes/chat/relay-pipeline.js +26 -0
  30. package/src/routes/groups-relay.js +85 -1
  31. package/src/routes/index.js +7 -1
  32. package/src/routes/relay-queue.js +53 -0
  33. package/src/runtime/bootstrap.js +14 -387
  34. package/src/runtime/broadband-stream.js +76 -0
  35. package/src/runtime/broadband.js +97 -0
  36. package/src/runtime/group-sync.js +28 -0
  37. package/src/runtime/providers-setup.js +147 -0
  38. package/src/runtime/server-lifecycle.js +156 -0
  39. package/src/state/facade.js +1 -0
  40. package/src/state/schemas/model.js +41 -0
  41. package/src/transport/index.js +248 -0
  42. package/src/transport/pool.js +60 -0
  43. package/src/transport/retry.js +24 -0
  44. package/src/transport/sse.js +93 -0
  45. package/src/upstream-responses.js +64 -0
  46. package/src/upstream.js +106 -334
@@ -1,15 +1,16 @@
1
- import { readFileSync, existsSync } from "node:fs";
2
1
  import { join } from "node:path";
3
- import { homedir } from "node:os";
4
2
  import { createModelsService } from "../../models.js";
5
3
  import { createUpstreamClient } from "../../upstream.js";
6
4
  import { logDir } from "../../logs.js";
7
- import { loadModelErrors, savePreferredModel, loadPreferredModel, loadModelPicks, saveModelPicks } from "../../state.js";
8
- import { getPreferredModel } from "../../auto.js";
9
- import { fmtShanghaiYMDHM } from "../../time.js";
10
- import { readModelsCache } from "../util.js";
11
- import { pickInteractiveMulti } from "../interactive.js";
5
+ import { handleModelStatus } from "./model/status.js";
6
+ import { handleModelStats } from "./model/stats.js";
7
+ import { handlePicksCommand } from "./model/picks.js";
8
+ import { handleModelList } from "./model/list.js";
12
9
 
10
+ /**
11
+ * `-model` 门面 — 仅分发:refresh 直连,status/stats/picks/list 下沉子模块。
12
+ * 对外接缝 `handleModel(args)` 不变(src/cli/index.js 动态 import)。
13
+ */
13
14
  export async function handleModel(args) {
14
15
  if (!(args.includes("-model") || args.includes("-models"))) return false;
15
16
  const idx = args.findIndex((x) => x === "-model" || x === "-models");
@@ -33,373 +34,18 @@ export async function handleModel(args) {
33
34
  process.exit(0);
34
35
  }
35
36
  if (sub === "status") {
36
- const statuses = loadModelErrors();
37
- const cacheFile = join(logDir(), "models.json");
38
- const cached = readModelsCache(cacheFile);
39
- const ids = new Set([
40
- ...(cached?.data || []).map((m) => m.id),
41
- ...Object.keys(statuses),
42
- ]);
43
- for (const id of ids) {
44
- const e = statuses[id];
45
- const st = typeof e === "number" ? "error" : e?.status || "normal";
46
- const at = typeof e === "number" ? e : e?.at;
47
- const when = at ? ` (${fmtShanghaiYMDHM ? fmtShanghaiYMDHM(at) : at})` : "";
48
- const extra = e?.code ? ` HTTP ${e.code}` : "";
49
- console.log(` ${id} ${st}${when}${extra}`);
50
- }
51
- process.exit(0);
52
- }
53
- if (sub === "set" && args[idx + 2]) {
54
- const id = args[idx + 2];
55
- savePreferredModel(id);
56
- const picks = [...new Set([...loadModelPicks(), id])];
57
- saveModelPicks(picks);
58
- console.log(`default model set to: ${id} (daemon hot-reloads on next request)`);
59
- console.log(`picked: ${picks.join(", ") || "(none)"} (auto will pick within these)`);
60
- process.exit(0);
61
- }
62
- if (sub === "pick" && args[idx + 2] && args[idx + 2] !== "clear") {
63
- const picks = [...new Set([...loadModelPicks(), args[idx + 2]])];
64
- saveModelPicks(picks);
65
- console.log(`picked: ${picks.join(", ") || "(none)"} (auto will pick within these)`);
66
- process.exit(0);
67
- }
68
- if (sub === "pick" && args[idx + 2] === "clear") {
69
- saveModelPicks([]);
70
- console.log("picks cleared — auto uses the full model list again");
71
- process.exit(0);
37
+ await handleModelStatus(args);
38
+ return true;
72
39
  }
73
- if (sub === "unpick" && args[idx + 2]) {
74
- const picks = loadModelPicks().filter((x) => x !== args[idx + 2]);
75
- saveModelPicks(picks);
76
- console.log(`picked: ${picks.join(", ") || "(none)"}${picks.length === 0 ? " (auto uses full list)" : ""}`);
77
- process.exit(0);
78
- }
79
- if (sub === "picks") {
80
- const picks = loadModelPicks();
81
- if (!picks.length) {
82
- console.log("no picks — auto uses the full model list");
83
- } else {
84
- console.log(`${picks.length} picked model(s), auto only selects within these:`);
85
- }
86
- for (const id of picks) console.log(` ${id}`);
87
- process.exit(0);
88
- }
89
- let modelListProvider = null;
90
- let modelListJson = false;
91
- if (sub === "list") {
92
- const restArgs = args.slice(idx + 2);
93
- for (let i = 0; i < restArgs.length; i++) {
94
- const a = String(restArgs[i] || "");
95
- if (a === "--json" || a === "-json") modelListJson = true;
96
- else if (a === "--provider" || a === "-provider" || a === "--providerId") { modelListProvider = String(restArgs[i + 1] || "").trim() || null; i++; }
97
- else if (!a.startsWith("-") && !modelListProvider) modelListProvider = a;
98
- }
99
- if (modelListProvider) {
100
- const { normalizeProviderId } = await import("../../providers/model-id.js");
101
- const nid = normalizeProviderId(modelListProvider);
102
- modelListProvider = nid || modelListProvider.toLowerCase();
103
- }
40
+ if (sub === "stats") {
41
+ await handleModelStats(args);
42
+ return true;
104
43
  }
44
+ if (await handlePicksCommand(args, idx, sub)) return true;
105
45
  if (sub !== undefined && sub !== "list") {
106
- console.error("usage: mslxdff -models (interactive multi-pick) | mslxdff -model list [--provider <id>] [--json] | mslxdff -model set <id> | mslxdff -model pick <id> | mslxdff -model unpick <id> | mslxdff -model pick clear | mslxdff -model picks | mslxdff -model status | mslxdff -model refresh");
107
- process.exit(1);
108
- }
109
- const cacheFile = join(logDir(), "models.json");
110
- async function tryRefreshModels() {
111
- try {
112
- const models = createModelsService({
113
- baseUrl: process.env.UPSTREAM_BASE_URL || "https://opencode.ai",
114
- headers: createUpstreamClient({}).headers,
115
- refreshMs: 0,
116
- cacheFile,
117
- });
118
- const list = await Promise.race([
119
- models.get(),
120
- new Promise((_, rej) => setTimeout(() => rej(new Error("refresh timeout")), 4000)),
121
- ]);
122
- return list;
123
- } catch {
124
- return null;
125
- }
126
- }
127
- try {
128
- let ids = [];
129
- let cachedAt = null;
130
- let refreshed = null;
131
- refreshed = await tryRefreshModels();
132
- if (refreshed?.data) {
133
- ids = (refreshed.data || []).map((m) => m.id).filter(Boolean);
134
- cachedAt = refreshed.cachedAt || Date.now();
135
- } else {
136
- const cached = readModelsCache(cacheFile);
137
- if (cached) {
138
- ids = (cached.data || []).map((m) => m.id).filter(Boolean);
139
- cachedAt = cached.cachedAt || null;
140
- } else {
141
- throw new Error("no cached models and refresh failed");
142
- }
143
- }
144
- if (modelListProvider) {
145
- const prov = String(modelListProvider).toLowerCase();
146
- const filtered = ids.filter((id) => {
147
- const slash = String(id).indexOf("/");
148
- const p = slash > 0 ? String(id).slice(0, slash).toLowerCase() : "opencode";
149
- return p === prov;
150
- });
151
- if (prov !== "opencode" && filtered.length === 0) {
152
- try {
153
- const { loadProviderAllowedModels, loadProviderAllowAnyModels, loadProviderBaseUrl } = await import("../../state.js");
154
- const { loadModelAliases, getAliasForModel } = await import("../../providers/model-id.js");
155
- try { loadModelAliases(); } catch {}
156
- const allowed = loadProviderAllowedModels(prov);
157
- const allowAny = loadProviderAllowAnyModels(prov);
158
- const baseUrl = loadProviderBaseUrl(prov);
159
- if (modelListJson) {
160
- const data = allowed.length
161
- ? allowed.map((raw) => ({ id: `${prov}/${raw}`, object: "model" }))
162
- : [];
163
- console.log(JSON.stringify({ object: "list", data }, null, 2));
164
- process.exit(0);
165
- }
166
- if (!allowed.length) {
167
- if (allowAny) {
168
- console.log(`provider "${prov}" allowAny ON (allowlist 空=放行全部)${baseUrl ? ` baseUrl=${baseUrl}` : ""}`);
169
- console.log(` (未设 allowlist,全部模型放行) 查看 live 列表: mslxdff -provider ${prov} models`);
170
- } else {
171
- console.log(`no models for provider "${prov}" — allowlist 空 + allowAny OFF = 阻塞`);
172
- console.log(` 设白名单: mslxdff -provider ${prov} allowlist set <model1> <model2> 或 mslxdff -provider ${prov} allowAny on`);
173
- console.log(` live 查看: mslxdff -provider ${prov} models`);
174
- }
175
- process.exit(0);
176
- }
177
- const at2 = cachedAt ? ` (cached ${fmtShanghaiYMDHM(cachedAt)})` : "";
178
- console.log(`${allowed.length} model(s) for ${prov}${at2} (allowlist,原名 + 别名):`);
179
- const pickedIds2 = loadModelPicks();
180
- for (const raw of allowed) {
181
- const canonical = `${prov}/${raw}`;
182
- let alias = null;
183
- try { alias = getAliasForModel(canonical); } catch {}
184
- if (!alias && String(canonical).includes("/")) alias = String(canonical).replace(/\//g, "-");
185
- const aliasStr = alias && alias !== canonical ? ` (别名: ${alias})` : "";
186
- const mark2 = pickedIds2.includes(canonical) || (alias && pickedIds2.includes(alias)) ? "*" : " ";
187
- console.log(` ${mark2} ${canonical}${aliasStr}`);
188
- }
189
- process.exit(0);
190
- } catch {}
191
- }
192
- ids = filtered;
193
- if (modelListJson) {
194
- console.log(JSON.stringify({ object: "list", data: ids.map((id) => ({ id, object: "model" })) }, null, 2));
195
- process.exit(0);
196
- }
197
- if (!ids.length) {
198
- console.log(`no models for provider "${prov}" — try: mslxdff -provider ${prov} models or mslxdff -model refresh`);
199
- process.exit(0);
200
- }
201
- } else if (modelListJson) {
202
- console.log(JSON.stringify({ object: "list", data: ids.map((id) => ({ id, object: "model" })) }, null, 2));
203
- process.exit(0);
204
- }
205
- if (!ids.length) {
206
- console.log("no models available — try: mslxdff -model refresh");
207
- process.exit(0);
208
- }
209
- if (sub === undefined && process.stdin.isTTY && process.stdout.isTTY) {
210
- const statuses = loadModelErrors();
211
- const current = getPreferredModel();
212
- const pickedIds = loadModelPicks();
213
- const combinedIds = [...ids];
214
- const seen = new Set(combinedIds);
215
- try {
216
- const { loadProviderConfigs, loadProviderAllowedModels } = await import("../../state.js");
217
- const configs = loadProviderConfigs();
218
- for (const pid of Object.keys(configs).filter((k) => String(k).toLowerCase() !== "opencode")) {
219
- const allowed = loadProviderAllowedModels(pid);
220
- for (const raw of allowed) {
221
- const canonical = `${pid}/${raw}`;
222
- if (!seen.has(canonical)) {
223
- seen.add(canonical);
224
- combinedIds.push(canonical);
225
- }
226
- }
227
- }
228
- } catch {}
229
- for (const pid of pickedIds) {
230
- if (!seen.has(pid)) {
231
- seen.add(pid);
232
- combinedIds.push(pid);
233
- }
234
- }
235
- const items = combinedIds.map((id) => {
236
- const e = statuses[id];
237
- return {
238
- id,
239
- status: typeof e === "number" ? "error" : e?.status || "normal",
240
- current: id === current,
241
- picked: pickedIds.includes(id),
242
- };
243
- });
244
- const result = await pickInteractiveMulti(items, new Set(pickedIds), Math.max(0, items.findIndex((x) => x.current)));
245
- if (!result) {
246
- console.log("cancelled — picks unchanged");
247
- process.exit(0);
248
- }
249
- saveModelPicks([...result]);
250
- console.log(`saved ${result.size} picked model(s): ${[...result].join(", ") || "(none — auto uses full list)"}`);
251
- process.exit(0);
252
- }
253
- const at = cachedAt ? ` (cached ${fmtShanghaiYMDHM(cachedAt)})` : "";
254
- const pickedIds = loadModelPicks();
255
- const mark = (id) => (pickedIds.includes(id) ? "*" : " ");
256
- const groups = {};
257
- for (const id of ids) {
258
- const prov = String(id).includes("/") ? String(id).split("/")[0] : "opencode";
259
- if (!groups[prov]) groups[prov] = [];
260
- groups[prov].push(id);
261
- }
262
- const order = ["opencode", "workbuddy", "clinebot", "openrouter"];
263
- const sortedProvs = Object.keys(groups).sort((a, b) => {
264
- const ia = order.indexOf(a), ib = order.indexOf(b);
265
- if (ia !== -1 || ib !== -1) {
266
- if (ia === -1) return 1;
267
- if (ib === -1) return -1;
268
- return ia - ib;
269
- }
270
- return a.localeCompare(b);
271
- });
272
- if (modelListProvider) {
273
- console.log(`${ids.length} model(s) for ${modelListProvider}${at} (${pickedIds.length} picked, * = picked):`);
274
- let aliasMap = {};
275
- try {
276
- const { loadModelAliases, getAliasForModel } = await import("../../providers/model-id.js");
277
- loadModelAliases();
278
- for (const id of ids) {
279
- const alias = getAliasForModel(id);
280
- if (alias) aliasMap[id] = alias;
281
- else if (String(id).includes("/")) {
282
- const dashAlias = String(id).replace(/\//g, "-");
283
- if (dashAlias !== id) aliasMap[id] = dashAlias;
284
- }
285
- }
286
- } catch {}
287
- for (const prov of sortedProvs) {
288
- const list = groups[prov];
289
- console.log(`\n ── ${prov} (${list.length}) ──`);
290
- for (const id of list) {
291
- const alias = aliasMap[id];
292
- const aliasStr = alias ? ` (别名: ${alias})` : "";
293
- console.log(` ${mark(id)} ${id}${aliasStr}`);
294
- }
295
- }
296
- console.log(`\npicked only constrains auto; manage with: mslxdff -models (TTY) | mslxdff -model pick <id> | mslxdff -model unpick <id> | mslxdff -model pick clear`);
297
- } else {
298
- console.log(`${ids.length} free model(s)${at} (${pickedIds.length} picked, * = picked):`);
299
- let aliasMap = {};
300
- let fullAliases = {};
301
- try {
302
- const { loadModelAliases, getAliasForModel } = await import("../../providers/model-id.js");
303
- loadModelAliases();
304
- for (const id of ids) {
305
- const alias = getAliasForModel(id);
306
- if (alias) aliasMap[id] = alias;
307
- else if (String(id).includes("/")) {
308
- const dashAlias = String(id).replace(/\//g, "-");
309
- if (dashAlias !== id) aliasMap[id] = dashAlias;
310
- }
311
- }
312
- try {
313
- const aliasesFile = join(homedir(), ".config", "mslxdff", "model-aliases.json");
314
- const raw = JSON.parse(readFileSync(aliasesFile, "utf8"));
315
- if (raw && typeof raw === "object") fullAliases = raw;
316
- } catch {}
317
- } catch {}
318
- for (const prov of sortedProvs) {
319
- const list = groups[prov];
320
- console.log(`\n ── ${prov} (${list.length}) ──`);
321
- for (const id of list) {
322
- const alias = aliasMap[id];
323
- const aliasStr = alias ? ` (别名: ${alias})` : "";
324
- console.log(` ${mark(id)} ${id}${aliasStr}`);
325
- }
326
- }
327
- try {
328
- const { loadProviderConfigs, loadProviderAllowedModels, loadProviderAllowAnyModels, loadProviderBaseUrl } = await import("../../state.js");
329
- const { loadModelAliases: _la2, getAliasForModel: _gaf } = await import("../../providers/model-id.js");
330
- try { _la2(); } catch {}
331
- const configs = loadProviderConfigs();
332
- const otherIds = Object.keys(configs).filter((k) => String(k).toLowerCase() !== "opencode");
333
- const order2 = ["workbuddy", "clinebot", "openrouter", "bai"];
334
- otherIds.sort((a, b) => {
335
- const ia = order2.indexOf(a), ib = order2.indexOf(b);
336
- if (ia !== -1 || ib !== -1) {
337
- if (ia === -1) return 1;
338
- if (ib === -1) return -1;
339
- return ia - ib;
340
- }
341
- return a.localeCompare(b);
342
- });
343
- if (otherIds.length) {
344
- console.log(`\n────────────────────────────────────────`);
345
- console.log(`其他供应商 (allowlist,原名 + 别名) (${otherIds.length} providers):`);
346
- for (const pid of otherIds) {
347
- const allowed = loadProviderAllowedModels(pid);
348
- const allowAny = loadProviderAllowAnyModels(pid);
349
- const baseUrl = loadProviderBaseUrl(pid) || configs[pid]?.baseUrl || "";
350
- const header = allowAny
351
- ? (allowed.length ? `allowlist ${allowed.length} (allowAny ON)` : `allowAny ON (allowlist 空=放行全部)`)
352
- : (allowed.length ? `allowlist ${allowed.length} (allowAny OFF)` : `allowlist 空 + allowAny OFF = 阻塞`);
353
- console.log(`\n ── ${pid} (${header})${baseUrl ? ` baseUrl=${baseUrl}` : ""} ──`);
354
- if (!allowed.length) {
355
- if (allowAny) {
356
- console.log(` (未设 allowlist,全部模型放行) 查看 live 列表: mslxdff -provider ${pid} models`);
357
- console.log(` 限制可用模型: mslxdff -provider ${pid} allowlist set <model1> <model2>`);
358
- } else {
359
- console.log(` 阻塞中:无可用模型 — 设白名单: mslxdff -provider ${pid} allowlist set <model1> <model2>`);
360
- console.log(` 或放行全部: mslxdff -provider ${pid} allowAny on`);
361
- }
362
- } else {
363
- for (const raw of allowed) {
364
- const canonical = `${pid}/${raw}`;
365
- let alias = null;
366
- try { alias = _gaf(canonical); } catch {}
367
- if (!alias && String(canonical).includes("/")) alias = String(canonical).replace(/\//g, "-");
368
- const aliasStr = alias && alias !== canonical ? ` (别名: ${alias})` : "";
369
- const pickedMark = pickedIds.includes(canonical) || pickedIds.includes(alias || "") ? "*" : " ";
370
- console.log(` ${pickedMark} ${canonical}${aliasStr}`);
371
- }
372
- console.log(` 管理: mslxdff -provider ${pid} allowlist [list|add|remove|clear] | allowAny on|off`);
373
- }
374
- }
375
- } else {
376
- console.log(`\n────────────────────────────────────────`);
377
- console.log(`其他供应商 (allowlist,原名 + 别名): (none — 尚未配置)`);
378
- console.log(` 添加示例: mslxdff -provider add myapi https://api.example.com/v1 sk-xxx --models-path /v1/models`);
379
- }
380
- const aliasEntries = Object.entries(fullAliases).filter(([alias, canonical]) => {
381
- if (ids.includes(canonical)) return false;
382
- for (const pid of otherIds) {
383
- const allowed = loadProviderAllowedModels(pid);
384
- for (const raw of allowed) {
385
- const can = `${pid}/${raw}`;
386
- if (can === canonical) return false;
387
- }
388
- }
389
- return true;
390
- });
391
- if (aliasEntries.length) {
392
- console.log(`\n 本地别名 (不在 allowlist 里的遗留映射 ${aliasEntries.length}):`);
393
- for (const [alias, canonical] of aliasEntries) {
394
- console.log(` ${canonical} => ${alias}`);
395
- }
396
- }
397
- } catch {}
398
- console.log(`\npicked only constrains auto; manage with: mslxdff -models (TTY) | mslxdff -model pick <id> | mslxdff -model unpick <id> | mslxdff -model pick clear`);
399
- }
400
- } catch (err) {
401
- console.error(`could not fetch models: ${String(err?.message || err)}`);
46
+ console.error("usage: mslxdff -models (interactive multi-pick) | mslxdff -model list [--provider <id>] [--json] | mslxdff -model set <id> | mslxdff -model pick <id> | mslxdff -model unpick <id> | mslxdff -model pick clear | mslxdff -model picks | mslxdff -model status [--all] | mslxdff -model stats [--all] | mslxdff -model refresh");
402
47
  process.exit(1);
403
48
  }
404
- process.exit(0);
49
+ await handleModelList(args, idx, sub);
50
+ return true;
405
51
  }
package/src/cli/help.js CHANGED
@@ -9,7 +9,8 @@ Usage:
9
9
  mslxdff -models interactive picker: ↑/↓ select a model, Enter sets it as the default (non-TTY: plain list)
10
10
  mslxdff -model list list the free models this proxy serves (cached)
11
11
  mslxdff -model set <id> set the default (preferred) model without the interactive picker
12
- mslxdff -model status show per-model health status (normal/limit/error)
12
+ mslxdff -model status [--all] show per-model health status (normal/limit/error, 默认隐藏孤儿 --all 看全部)
13
+ mslxdff -model stats [--all] 监控:每模型 请求/首字/总耗时/速度 样本(--all 含孤儿,精简版 -status)
13
14
  mslxdff -model refresh force-refresh the model cache from the upstream
14
15
  mslxdff -debug live-follow the daemon event stream (requests, errors, peer forwards)
15
16
  mslxdff -stop stop the running daemon
@@ -1,5 +1,6 @@
1
1
  import { joinUrl, sleep } from "../base.js";
2
2
  import { clineHeaders } from "./headers.js";
3
+ import { createTransport } from "../../transport/index.js";
3
4
 
4
5
  function genSessionId() { return `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; }
5
6
 
@@ -12,40 +13,27 @@ function unwrapData(obj) {
12
13
  }
13
14
 
14
15
  async function streamToNonStream(upstream) {
15
- const reader = upstream.body.getReader();
16
- const decoder = new TextDecoder();
17
- let buf = "";
18
16
  let content = "";
19
17
  let reasoning = "";
20
18
  let finishReason = null;
21
19
  let model = "";
22
20
  let id = "";
23
21
  let usage = null;
24
- while (true) {
25
- const { done, value } = await reader.read();
26
- if (done) break;
27
- buf += decoder.decode(value, { stream: true });
28
- let idx;
29
- while ((idx = buf.indexOf("\n")) >= 0) {
30
- const line = buf.slice(0, idx);
31
- buf = buf.slice(idx + 1);
32
- if (!line.startsWith("data:")) continue;
33
- const payload = line.slice(5).trim();
34
- if (!payload || payload === "[DONE]") continue;
35
- try {
36
- const obj = JSON.parse(payload);
37
- const normalized = unwrapData(obj);
38
- const choice = normalized?.choices?.[0];
39
- if (!choice) continue;
40
- const delta = choice.delta || {};
41
- if (delta.content) content += delta.content;
42
- if (delta.reasoning) reasoning += delta.reasoning;
43
- if (choice.finish_reason) finishReason = choice.finish_reason;
44
- if (normalized.id) id = normalized.id;
45
- if (normalized.model) model = normalized.model;
46
- if (normalized.usage) usage = normalized.usage;
47
- } catch {}
48
- }
22
+ for await (const ev of upstream.stream()) {
23
+ if (!ev || ev === "[DONE]") continue;
24
+ try {
25
+ const obj = JSON.parse(ev);
26
+ const normalized = unwrapData(obj);
27
+ const choice = normalized?.choices?.[0];
28
+ if (!choice) continue;
29
+ const delta = choice.delta || {};
30
+ if (delta.content) content += delta.content;
31
+ if (delta.reasoning) reasoning += delta.reasoning;
32
+ if (choice.finish_reason) finishReason = choice.finish_reason;
33
+ if (normalized.id) id = normalized.id;
34
+ if (normalized.model) model = normalized.model;
35
+ if (normalized.usage) usage = normalized.usage;
36
+ } catch {}
49
37
  }
50
38
  const msg = { role: "assistant", content };
51
39
  if (reasoning) msg.reasoning = reasoning;
@@ -68,42 +56,34 @@ export function createChatService({
68
56
  dispatcher,
69
57
  authPool,
70
58
  connectTimeoutMs = 30_000,
71
- retry = { network: { attempts: 2, delayMs: 300 } },
72
59
  } = {}) {
73
60
  const resolvedBase = String(baseUrl).trim().replace(/\/+$/, "");
74
- // chat 固定落在 /api/v1/chat/completions:base 已含 /api/v1 则只拼 /chat/completions,
75
- // 否则拼 /api/v1/chat/completions(避免双 /api/v1 或 /v1 错路径导致 401/empty response)
76
61
  const resolvedChat = chatPath || (String(resolvedBase).includes("/api/v1") ? "/chat/completions" : "/api/v1/chat/completions");
62
+ const transport = createTransport({ fetchImpl, dispatcher, keepAlive: !!dispatcher, timeoutMs: connectTimeoutMs, retry: {} });
77
63
 
78
64
  async function clineFetch(body, sessionId) {
79
65
  const token = await authPool.getAccessToken();
80
66
  const headers = clineHeaders(sessionId, token);
81
67
  const finalUrl = joinUrl(resolvedBase, resolvedChat);
82
- const opts = { method: "POST", headers, body: JSON.stringify(body) };
83
- if (dispatcher) opts.dispatcher = dispatcher;
84
- const controller = new AbortController();
85
- const timer = setTimeout(() => controller.abort(new Error(`${id} timed out after ${connectTimeoutMs}ms`)), connectTimeoutMs);
86
- opts.signal = controller.signal;
87
- try { return await fetchImpl(finalUrl, opts); } finally { clearTimeout(timer); }
68
+ const isStream = body?.stream === true;
69
+ return transport.request({ url: finalUrl, headers, body, stream: isStream, timeoutMs: connectTimeoutMs });
88
70
  }
89
71
 
90
- // 判断限流信号
91
- function isLimitHit(status, bodyText, isStream) {
72
+ function isLimitHit(status, bodyText) {
92
73
  if (status === 429) return true;
93
74
  if (status >= 500 && String(bodyText).includes("empty response content")) return true;
94
- if (status === 200 && !isStream && String(bodyText).includes("empty response content")) return true;
95
75
  return false;
96
76
  }
97
77
 
98
- async function clineFetchWithRetry(body, sessionId, isStream) {
78
+ async function clineFetchWithRetry(body, sessionId) {
99
79
  const maxRetries = 4;
100
80
  let lastResp = null;
101
81
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
102
82
  const resp = await authPool.enqueue(() => clineFetch(body, sessionId));
103
83
  lastResp = resp;
104
84
  let bodyText = "";
105
- try { bodyText = await resp.clone().text(); } catch {}
106
- const hit = isLimitHit(resp.status, bodyText, isStream);
85
+ if (resp.status !== 200) { try { bodyText = await resp.text(); } catch {} }
86
+ const hit = isLimitHit(resp.status, bodyText);
107
87
  if (hit) {
108
88
  const { parseCooldown } = await import("./auth.js");
109
89
  const cooldownMs = parseCooldown(bodyText, resp.status);
@@ -126,7 +106,7 @@ export function createChatService({
126
106
  let lastData = null;
127
107
  let resp = firstResp;
128
108
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
129
- if (!resp) resp = await clineFetchWithRetry(body, sessionId, true);
109
+ if (!resp) resp = await clineFetchWithRetry(body, sessionId);
130
110
  if (!resp.ok) {
131
111
  const errText = await resp.text().catch(() => "");
132
112
  const hdrs = new Headers(resp.headers);
@@ -165,7 +145,6 @@ export function createChatService({
165
145
  const sessionId = genSessionId();
166
146
  const isStream = body?.stream === true;
167
147
  const upstreamModel = String(model).split("/").pop().includes(":") ? model : model;
168
- // 构造上游 body:保留外部 model 名,Cline 上游用同名
169
148
  const upstreamBody = {
170
149
  model: upstreamModel,
171
150
  max_tokens: body?.max_tokens || body?.max_completion_tokens || 4096,
@@ -178,16 +157,11 @@ export function createChatService({
178
157
  for (const k of ["temperature", "top_p", "tools", "tool_choice", "stop", "presence_penalty", "frequency_penalty", "response_format", "user", "n", "seed"]) {
179
158
  if (body[k] !== undefined) upstreamBody[k] = body[k];
180
159
  }
181
-
182
- // 网络层重试
183
160
  for (let netAttempt = 0; netAttempt < 3; netAttempt++) {
184
161
  try {
185
- const resp = await clineFetchWithRetry(upstreamBody, sessionId, true);
162
+ const resp = await clineFetchWithRetry(upstreamBody, sessionId);
186
163
  if (!resp) throw new Error("empty response");
187
- if (!resp.ok) {
188
- // 直通错误(403/400 等)
189
- return resp;
190
- }
164
+ if (!resp.ok) return resp;
191
165
  if (isStream) return resp;
192
166
  if (forceStream) {
193
167
  const ret = await nonStreamWithContentCheck(upstreamBody, sessionId, resp);
@@ -196,7 +170,6 @@ export function createChatService({
196
170
  const hdrs = new Headers({ "Content-Type": "application/json" });
197
171
  return new Response(JSON.stringify(ret.data), { status: 200, headers: hdrs });
198
172
  }
199
- // 普通非流式(非 deepseek)
200
173
  const raw = await resp.json().catch(() => null);
201
174
  if (!raw) return resp;
202
175
  const normalized = unwrapData(raw);