mslxdff 0.1.89 → 0.1.91

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.
@@ -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
@@ -4,6 +4,16 @@ import { createTransport } from "../../transport/index.js";
4
4
 
5
5
  function genSessionId() { return `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; }
6
6
 
7
+ // stripProviderPrefix 去掉本地供应商前缀,发上游用裸模型 id。
8
+ // 上游(对标 cline2api-workers MODELS)只认裸 id:"deepseek/deepseek-v4-flash"、
9
+ // "poolside/laguna-s-2.1:free";本地 "clinebot/deepseek/..." 需取后两段。
10
+ // 两段及以内视为已是裸 id,原样透传。
11
+ function stripProviderPrefix(id) {
12
+ const parts = String(id || "").split("/").filter(Boolean);
13
+ if (parts.length > 2) return parts.slice(1).join("/");
14
+ return String(id || "");
15
+ }
16
+
7
17
  function unwrapData(obj) {
8
18
  if (obj && obj.data && typeof obj.data === "object") {
9
19
  const d = obj.data;
@@ -144,7 +154,7 @@ export function createChatService({
144
154
  const model = body?.model || "deepseek/deepseek-v4-flash";
145
155
  const sessionId = genSessionId();
146
156
  const isStream = body?.stream === true;
147
- const upstreamModel = String(model).split("/").pop().includes(":") ? model : model;
157
+ const upstreamModel = stripProviderPrefix(model);
148
158
  const upstreamBody = {
149
159
  model: upstreamModel,
150
160
  max_tokens: body?.max_tokens || body?.max_completion_tokens || 4096,
@@ -171,7 +181,10 @@ export function createChatService({
171
181
  return new Response(JSON.stringify(ret.data), { status: 200, headers: hdrs });
172
182
  }
173
183
  const raw = await resp.json().catch(() => null);
174
- if (!raw) return resp;
184
+ if (!raw || typeof raw !== "object") {
185
+ if (!raw) return resp;
186
+ return new Response(JSON.stringify({ error: { message: "upstream returned non-JSON body", type: "api_error" } }), { status: 502, headers: { "Content-Type": "application/json" } });
187
+ }
175
188
  const normalized = unwrapData(raw);
176
189
  normalized.model = model;
177
190
  return new Response(JSON.stringify(normalized), { status: 200, headers: { "Content-Type": "application/json" } });
@@ -1,4 +1,7 @@
1
1
  import { runHook } from "../../plugins.js";
2
+ import { recordModelStats } from "../../state.js";
3
+ import { normalizeFullId } from "../../providers/model-id.js";
4
+ import { computeMetrics, extractUsageFromJson, extractUsageFromSseText } from "../../metrics.js";
2
5
 
3
6
  /**
4
7
  * RelayPipeline 深模块
@@ -141,6 +144,29 @@ export function createRelayPipeline({
141
144
  try { await auto.recordLatency(actual, latencyMs); } catch {}
142
145
  }
143
146
 
147
+ // 每次 8989 正常返回都落体检:count/首字/总耗时/速度(供 -status TopN)
148
+ if (out.status === 200) {
149
+ try {
150
+ const isStream = Boolean(body?.stream);
151
+ let ttfb = isStream ? (out.ttfMs ?? upRes?._t?.ttfbMs ?? null) : null;
152
+ // out.totalMs 为 0 时(非流式 <1ms 四舍五入)回退到 elapsed/durationMs
153
+ const elapsedFallback = Date.now() - curStartedAt;
154
+ let total = out.totalMs;
155
+ if (!Number.isFinite(total) || total <= 0) total = elapsedFallback;
156
+ if (!Number.isFinite(total) || total <= 0) total = latencyMs;
157
+ if (!Number.isFinite(total) || total <= 0) total = Date.now() - curStartedAt;
158
+ if (isStream && (!Number.isFinite(ttfb) || ttfb <= 0)) ttfb = null;
159
+ const usage = out.detail?.usage || null;
160
+ const chars = out.detail?.chars ?? null;
161
+ const compTok = usage?.completion_tokens ?? null;
162
+ const m = computeMetrics({ ttfbMs: ttfb, totalMs: total, completionTokens: compTok, chars });
163
+ const tps = m.tps ?? m.charsPerSec ?? null;
164
+ const fullId = normalizeFullId(actual);
165
+ recordModelStats(fullId, { ttfbMs: ttfb, totalMs: total, tps, completionTokens: compTok });
166
+ if (fullId !== actual) recordModelStats(actual, { ttfbMs: ttfb, totalMs: total, tps, completionTokens: compTok });
167
+ } catch {}
168
+ }
169
+
144
170
  _evt("result", { reqId, model: actual, status: out.status, via, timing: upRes?._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null, fallback, requested, actual });
145
171
  _evt("client-response", { requested, actual, via, fallback, status: out.status, reqId });
146
172
  if (plugins?.length) runHook(plugins, "request:completed", { reqId, requested, useAuto, hops, stream: Boolean(body?.stream), durationMs: Date.now() - curStartedAt, via, status: out.status, actual, fallback }).catch(() => {});