mslxdff 0.1.159 → 0.1.161

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 (59) hide show
  1. package/docs/cli_help_mini.md +3 -1
  2. package/package.json +1 -1
  3. package/src/autostart.js +3 -0
  4. package/src/chat-pipeline/auto-race.js +1 -1
  5. package/src/chat-pipeline/empty-turn.js +119 -0
  6. package/src/chat-pipeline/index.js +4 -1
  7. package/src/chat-pipeline/serial-trial.js +244 -210
  8. package/src/cli/commands/daemon.js +4 -4
  9. package/src/cli/commands/model/list-providers.js +1 -1
  10. package/src/cli/commands/provider/globalqwenwork-login.js +126 -0
  11. package/src/cli/commands/provider/index.js +2 -0
  12. package/src/cli/commands/provider/models.js +4 -1
  13. package/src/cli/commands/stats.js +20 -5
  14. package/src/cli/commands/system.js +2 -2
  15. package/src/cli/format.js +6 -0
  16. package/src/cli/policy.js +2 -2
  17. package/src/cli/provider-row.js +2 -2
  18. package/src/daemon.js +7 -2
  19. package/src/logs.js +14 -1
  20. package/src/model-trace.js +5 -2
  21. package/src/providers/AGENTS.md +46 -0
  22. package/src/providers/classify.js +1 -1
  23. package/src/providers/globalqwenwork/account-store.js +141 -0
  24. package/src/providers/globalqwenwork/constants.js +73 -0
  25. package/src/providers/globalqwenwork/cosy.js +123 -0
  26. package/src/providers/globalqwenwork/crypto.js +220 -0
  27. package/src/providers/globalqwenwork/http.js +22 -0
  28. package/src/providers/globalqwenwork/index.js +331 -0
  29. package/src/providers/globalqwenwork/payload.js +145 -0
  30. package/src/providers/globalqwenwork/rsa.js +56 -0
  31. package/src/providers/globalqwenwork/sse.js +270 -0
  32. package/src/providers/globalqwenwork/stream.js +132 -0
  33. package/src/providers/globalqwenwork/upstream.js +122 -0
  34. package/src/providers/globalqwenwork.js +1 -0
  35. package/src/providers/registry.js +8 -0
  36. package/src/providers/zcode/chat.js +11 -10
  37. package/src/providers/zcode/const.js +1 -1
  38. package/src/providers/zcode/context-shape.js +162 -0
  39. package/src/providers/zcode/headers.js +38 -0
  40. package/src/providers/zcode/sse.js +19 -2
  41. package/src/routes/AGENTS.md +37 -0
  42. package/src/routes/chat/broadband-handler.js +3 -2
  43. package/src/routes/chat/exhausted-handler.js +23 -8
  44. package/src/routes/chat/hedge-handler.js +3 -0
  45. package/src/routes/chat/local-handler.js +5 -1
  46. package/src/routes/chat/peer-handler.js +2 -0
  47. package/src/routes/chat/relay-pipeline.js +40 -14
  48. package/src/routes/chat/via-route-handler.js +2 -0
  49. package/src/routes/helpers.js +20 -4
  50. package/src/routes/stream-hold.js +138 -0
  51. package/src/routes/stream-scan.js +278 -0
  52. package/src/routes/stream.js +106 -158
  53. package/src/runtime/lifecycle-forensics.js +116 -0
  54. package/src/runtime/lifecycle-log.js +23 -0
  55. package/src/runtime/provider-gate.js +4 -3
  56. package/src/talk-log.js +226 -0
  57. package/src/timeline.js +5 -2
  58. package/src/usage/record.js +8 -1
  59. package/src/usage/report.js +39 -2
@@ -17,7 +17,7 @@
17
17
  | 无参启动 | `mslxdff` | 已有 daemon 显示 status,否则后台启动 |
18
18
  | daemon | `-d` / `--daemon` | 后台启动(只升不降,低版本不覆盖高版本) |
19
19
  | 状态 | `-status` / `--status` / `-s` | 打印 daemon/health/port/config、upstream providers(启用/key/baseUrl/allowlist/共享)、models(含 v0.1.59 体检表 avg首字/tps/啰嗦/p95)/群组/failover/recent calls(ts/model/status/dur)/last error/autostart/plugins — 全量聚合体检 |
20
- | 用量报表 | `-stats` / `--stats` `[--hours N] [--json] [--model <id>]` | 近 N 小时(默认24,上限168)模型用量;默认输出 `Token 用量`(请求/输入/输出/思考/合计)与 `响应性能`(首字/总耗时/加权速度)两张自适应边框表并含合计,长模型 id 不截断;大 token 用 k/M 缩写,`--json` 为精确值。速度按窗口加权 `Σ输出÷Σ生成耗时`,只计成功请求;不含失败请求、`-chat` 直连,也不展开逐请求 via/interrupted/单次 tps;查询超过保留期会警告可能不完整 |
20
+ | 用量报表 | `-stats` / `--stats` `[--hours N] [--json] [--model <id>]` | 近 N 小时(默认24,上限168)模型用量;输出 `Token 用量`(请求/输入/输出/思考/合计)与 `响应性能`(首字/总耗时/加权速度/**首字样本 n/N**)两张自适应边框表并含合计,长 id 不截断;k/M 缩写,`--json` 精确值。**首字 = 本次上游尝试开始 → 网关转发首个真实数据帧**(含排队/建连/判决预读,ADR-0039),`0ms` 有效、非流式不进分子与分母,N=0 显示 `—`;**思考四态**:上报值 / `~` 估算(思考字符÷4)/ `0` 真无 / `—` 未知(不再拿 0 冒充「不思考」);与 `-status`/`-model stats` 的 EMA 首字**不同源**;速度仍为窗口加权 `Σ输出÷Σ生成耗时`;不含失败请求与 `-chat` 直连 |
21
21
  | 日志 | `-log [N]` / `--log [N]` / `-logs N` | 最近 N 条事件 + `timeline.log` 人读时间线;按模型链路日志为 `logDir/<provider>-<model>.log`,逐阶段记录 request/route/upstream/peer/relay/result(安全摘要,不落 prompt/正文/凭据)。**事件面用黑名单**:默认全部可见,只排除 `peer-health`/`heartbeat`/`client-session`/`upstream-probe*`;决定类事件只渲染登记字段,含上游回显 `upstream=`/`account=`/`pick=`/`cooled=` |
22
22
  | 调试 | `-debug` / `--debug` | 前台跟随事件流,Ctrl+C 恢复后台 |
23
23
  | 插件 | `-plugins` / `--plugins` | 列插件与 hooks |
@@ -80,6 +80,8 @@
80
80
  | 上游引擎(默认,ADR-0017) | `MSLXDFF_UPSTREAM_ENGINE`(缺省 `sdk`) | opencode 流式 chat 走 `@ai-sdk/openai-compatible`、`muse-spark*` 走 `@ai-sdk/openai` 的 responses 适配器(响应标记头 `x-mslxdff-upstream-engine: sdk`,复用 legacy 连接池);通用 OpenAI 兼容族与 cline 同源(供应商级 `MSLXDFF_<ID>_SDK` 未设置即继承本变量);非流式自动委派 legacy,SDK 不可用回退并告警;显式 `legacy`/关闭词回退原实现 |
81
81
  | responses 类模型路由(ADR-0032) | `MSLXDFF_<ID>_RESPONSES_PATH`(缺省 `/responses`) | 通用带 key 供应商遇 responses 类模型(`muse-spark*` 等,判定与 `/v1/models` 的 `capabilities.upstreamApi` 同源)自动改打 `<baseUrl>/responses`(此前固定打 `chatPath` → 上游 503 `Endpoint is unavailable`);流式走 `@ai-sdk/openai` responses 适配器(含加密思考往返),非流式/SDK 不可用走原生 `chatToResponsesBody` 转换,出参恒 chat 形状;异形上游用该 env 覆盖路径 |
82
82
  | SDK 通道 headers 超时 | `MSLXDFF_SDK_HEADERS_TIMEOUT_MS`(缺省 `120000`) | 防 SDK 通道挂死:`doStream` 到点仍未返回响应头即判挂死抛错(由调用方回退/换路),`0`=关闭;错误文案**不含 "timed out"**(避免 cline runChat 按文案重试放大挂死) |
83
+ | 空转重试阶梯 | `MSLXDFF_EMPTY_TURN_RETRY_STEPS`(缺省 `[2000,8000,30000]`)/`MSLXDFF_EMPTY_TURN_RETRIES`/`MSLXDFF_EMPTY_TURN_MAX_WAIT_MS`(缺省 60000) | 上游 200 但零正文(客户端表现为 "The model ended its turn without producing any output")时同模型重拉:按阶梯逐档 2s→8s→30s 等待,次数缺省跟随档数(`0`=关,空串=未设);上游报 `retryAfterSeconds` 时本次等待被抬到不低于该窗口(封顶 `MAX_WAIT_MS`,防上游文本把请求挂 1 小时);`STEPS=[]`=逃生阀回旧式固定 2s×2;非法值告警一次并落回默认阶梯(手滑不清零恢复能力);事件 `empty-turn-retry` 带 `step/delayMs/waitedMs(累计)` |
84
+ | 空轮留口与终局收场(ADR-0043) | `MSLXDFF_EMPTY_TURN_HOLD_END`(默认开)/`MSLXDFF_EMPTY_TURN_BUDGET_MS`(45000)/`MSLXDFF_EMPTY_TURN_MIN_RAISE_TO`(16384) | 「200 但零正文」的空轮曾先 `res.end()` 再判定,重拉/换候选写的是已终结响应(客户端只看到空白)。现把「可撤销」定义为**尚未提交模型产出**:空 delta 帧与 `[DONE]` 暂扣不写、流结束不封口,交回重拉续写同一条连接;思考已刷出的一类撤不回 → 不留口,改在流尾补 `EMPTY_MODEL_RESPONSE` SSE 错误帧 + `[DONE]`(客户端看到具体原因而非莫名空白)。终局按 `res.headersSent` 分叉(未 flush→502 JSON / 已 flush→SSE 错误帧),单一出口 + `writableEnded` 守卫保证恰好一次;预算=请求级跨候选累计封顶;`MIN_RAISE_TO` 给"客户端没设额度"的主流空轮兜底抬一次(`0`=回旧口径不发明);`HOLD_END=0` 逐字节回退旧行为 |
83
85
  | 免费层形状门禁(ADR-0020) | `MSLXDFF_FREE_LANE`(缺省 1)/`MSLXDFF_FREE_LANE_DEBUG=1` | zen 免费模型必须 `stream:true` + tools 含 bash/edit/glob/grep/read 五名且 UA≥`opencode/1.18.0`(否则 403/426);`src/free-lane.js` 自动补形状、非流式聚合回 JSON;`0`=关(逃生阀),DEBUG 打 `[free-lane]` 日志 |
84
86
  | WorkBuddy 摘除 | `-workbuddy remove <uid> [--keep-file]` / `-wb remove` | 按 `uid`(前缀6位)摘除,删 `keys/auths` 与 `auths/workbuddy-<uid>.json` |
85
87
  | 定号消耗 | `header x-mslxdff-workbuddy-uid: <uid>` 或 `model workbuddy/<uid>:<model>` | 钉死指定账号消耗,`x-mslxdff-workbuddy-uid` 回显实际账号 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.159",
3
+ "version": "0.1.161",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/autostart.js CHANGED
@@ -3,6 +3,8 @@ import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node
3
3
  import { homedir, userInfo } from "node:os";
4
4
  import { join, dirname } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { daemonDir, readPid } from "./daemon.js";
7
+ import { writeExitMarker } from "./runtime/lifecycle-forensics.js";
6
8
 
7
9
  const TASK_NAME = "mslxdff";
8
10
  const SERVICE_NAME = "mslxdff";
@@ -203,6 +205,7 @@ async function linuxEnable() {
203
205
  }
204
206
  for (const p of pids) {
205
207
  if (p === process.pid) continue;
208
+ if (p === readPid()) { try { writeExitMarker(daemonDir(), { reason: "autostart-cleanup", prevPid: p, prevVersion: null, byPid: process.pid }); } catch {} }
206
209
  try { process.kill(p, "SIGTERM"); killed++; } catch {}
207
210
  }
208
211
  if (killed) await new Promise((r2) => setTimeout(r2, 400));
@@ -101,7 +101,7 @@ export async function runAutoRace(ctx, deps = {}) {
101
101
  else if (r.status === "rejected") await auto.recordError(m, { status: 502 });
102
102
  }
103
103
  handlerCtx.model = winModel;
104
- const lr = await localRelay({ upRes: best.val.res, model: winModel, body, order: raceModels, idx: best.idx, lastErr: null, requested, useAuto, lockModel, auto, handlerCtx, evt, logCall, logError, mark, perf0, stages, startedAt, plugins, res });
104
+ const lr = await localRelay({ upRes: best.val.res, model: winModel, body, order: raceModels, idx: best.idx, lastErr: null, requested, useAuto, lockModel, auto, handlerCtx, evt, logCall, logError, mark, perf0, attemptStartMs: raceStart, stages, startedAt, plugins, res });
105
105
  if (lr.handled) return { done: true };
106
106
  if (!lr.lastErr) return { done: true };
107
107
  } else {
@@ -0,0 +1,119 @@
1
+ // 空转(EMPTY_MODEL_RESPONSE:上游 200 但零正文零工具调用)重试策略:阶梯等待 + 抬额度 + 末次追问。
2
+ // 本模块只做「配置解析 + 载荷改写」的纯决策,不碰 HTTP 与下游响应;执行编排在 serial-trial.js。
3
+ // 拆出原因:serial-trial.js 承载串行换人/hedge/peer/broadband 编排,再加政策面即破 20KB 硬门。
4
+ import { extractRetryAfterSec } from "../routes/stream-scan.js"; // 限流窗口解析:与 relay 的「错误包络暂扣」同源,不复制第二份正则
5
+
6
+ // 空转重试阶梯(用户定:网络质量差 → 2s→8s→30s;末档对齐实测 retryAfterSeconds=30 的限流窗口)。
7
+ // MSLXDFF_EMPTY_TURN_RETRY_STEPS: JSON 数组(ms)。未设 = 用默认阶梯 [2000,8000,30000];显式 "[]" = 逃生阀,回旧式固定延迟。
8
+ // MSLXDFF_EMPTY_TURN_RETRIES: 重试上限。未设 = 阶梯档数(默认 3);0 = 关闭重试(空转直接换候选/终结)。
9
+ // 非法值(语法错/非正数)→ 每进程告警一次并按默认阶梯跑:绝不因配置手滑把重试整体关掉。
10
+ export const DEFAULT_EMPTY_TURN_STEPS = Object.freeze([2000, 8000, 30000]);
11
+
12
+ let stepsWarned = false;
13
+ function warnBadSteps(raw) {
14
+ if (stepsWarned) return;
15
+ stepsWarned = true;
16
+ try { console.warn(`[mslxdff] MSLXDFF_EMPTY_TURN_RETRY_STEPS 非法(${String(raw).slice(0, 60)}) → 改用默认阶梯 [${DEFAULT_EMPTY_TURN_STEPS.join(",")}]`); } catch {}
17
+ }
18
+
19
+ export function emptyRetryCfg() {
20
+ // 空串/纯空白 = 未设(.env 里 `MSLXDFF_EMPTY_TURN_RETRIES=` 是常见手滑,Number("")===0 会把整条恢复静默清零)
21
+ const rawMax = String(process.env.MSLXDFF_EMPTY_TURN_RETRIES ?? "").trim();
22
+ const r = rawMax === "" ? NaN : Number(rawMax);
23
+ const resolveMax = (fallback) => (Number.isInteger(r) && r >= 0 ? r : fallback);
24
+ const raw = process.env.MSLXDFF_EMPTY_TURN_RETRY_STEPS;
25
+ const s = raw == null ? "" : String(raw).trim();
26
+ // 逃生阀:显式空数组 → 旧式固定延迟(MSLXDFF_EMPTY_TURN_RETRY_DELAY_MS,默认 2s、最多 2 次)
27
+ if (s === "[]") {
28
+ const d = Number(process.env.MSLXDFF_EMPTY_TURN_RETRY_DELAY_MS);
29
+ return { max: resolveMax(2), delayMs: Number.isFinite(d) && d >= 0 ? d : 2000, steps: null };
30
+ }
31
+ let steps = DEFAULT_EMPTY_TURN_STEPS;
32
+ if (s !== "") {
33
+ let parsed = null;
34
+ try { parsed = JSON.parse(s); } catch { warnBadSteps(s); }
35
+ if (Array.isArray(parsed) && parsed.length && parsed.every((v) => Number.isFinite(Number(v)) && Number(v) > 0)) {
36
+ steps = parsed.map(Number);
37
+ } else if (parsed !== null) {
38
+ warnBadSteps(s); // 语法过了但形状不对(非数组/空数组元素/含非正数)
39
+ }
40
+ }
41
+ return { max: resolveMax(steps.length), steps };
42
+ }
43
+
44
+ // retryAfterSeconds 来自上游可控文本,不设顶就会被拿捏:`retryAfterSeconds: 3600` → 单档挂 1 小时;
45
+ // 且 ms 数超过 setTimeout 上限时 Node 会钳成 1ms 并告警 → 对刚限流的上游连开三枪,正是要防的事。
46
+ // 顶只约束「上游能 imposed 多少」;用户自己配的更大阶梯档照办(那是显式意愿),仅保留 setTimeout 硬安全顶。
47
+ const SET_TIMEOUT_CEILING_MS = 2_147_483_647;
48
+ export function emptyTurnMaxWaitMs() {
49
+ const v = Number(process.env.MSLXDFF_EMPTY_TURN_MAX_WAIT_MS);
50
+ return Number.isFinite(v) && v > 0 ? Math.min(v, SET_TIMEOUT_CEILING_MS) : 60_000;
51
+ }
52
+
53
+ // 本次重试实际该等多久:按档取阶梯(超出档数循环复用),再按上游 retryAfterSeconds 抬到不低于该窗口。
54
+ // 限流没到点就重拉等于白烧一发(实测 2s 重拉撞 retryAfterSeconds=30 → 连 2/2 全灭,正是本次改动起因)。
55
+ export function computeNextDelay(stepIndex, steps, lastErr) {
56
+ const base = Number(process.env.MSLXDFF_EMPTY_TURN_RETRY_DELAY_MS);
57
+ let delayMs = Number.isFinite(base) && base >= 0 ? base : 2000;
58
+ if (Array.isArray(steps) && steps.length && Number.isInteger(stepIndex) && stepIndex >= 0) {
59
+ delayMs = steps[stepIndex % steps.length];
60
+ }
61
+ const after = extractRetryAfterSec(lastErr?.message || "");
62
+ if (after != null) delayMs = Math.max(delayMs, Math.min(after * 1000, emptyTurnMaxWaitMs()));
63
+ return Math.min(Math.round(delayMs), SET_TIMEOUT_CEILING_MS);
64
+ }
65
+
66
+ // 空转重试抬额度:思考型模型常把 max_tokens 全花在 reasoning 上(实测 8192 刷满 → 零正文
67
+ // finish_reason=length),同参重拉必然复现 → 重试前把额度翻倍抬到顶,给模型"想完还能说话"的空间。
68
+ // 只加不减、有顶;客户端没设额度时由调用方决定是否兜底发明一次(floor 参数,见 emptyTurnMinRaiseTo;floor=0 回旧口径不发明)。
69
+ // MSLXDFF_EMPTY_TURN_RAISE_TOKENS=0 关闭抬额;默认顶 16384。
70
+ export function emptyRaiseCap() {
71
+ const c = Number(process.env.MSLXDFF_EMPTY_TURN_RAISE_TOKENS);
72
+ return Number.isInteger(c) && c >= 0 ? c : 16384;
73
+ }
74
+
75
+ // 空转重试抬额度(含兜底):现网三类空轮里最多的是「思考吃满 max_tokens、零正文」
76
+ // (events.log:reasoningChars 10290/22652、wroteFrames 1972/2573、finish=length)。客户端设过额度 → 翻倍抬到 cap;
77
+ // 客户端根本没设额度 → 同参重拉必然复现,故用 MIN_RAISE_TO 兜底发明一次上限(=0 关,回旧口径「不替它发明」)。
78
+ export function emptyTurnMinRaiseTo() {
79
+ const v = Number(process.env.MSLXDFF_EMPTY_TURN_MIN_RAISE_TO);
80
+ if (v === 0 || (Number.isFinite(v) && v < 0)) return 0;
81
+ return Number.isInteger(v) && v > 0 ? v : 16384;
82
+ }
83
+
84
+ export function withRaisedMaxTokens(payload, cap, floor = 0) {
85
+ if (!payload || typeof payload !== "object") return payload;
86
+ for (const key of ["max_tokens", "max_completion_tokens"]) {
87
+ const cur = Number(payload[key]);
88
+ if (Number.isFinite(cur) && cur > 0) {
89
+ if (!cap || cur >= cap) return payload; // 已到顶:只剩同参重拉(赌偶发空轮)
90
+ return { ...payload, [key]: Math.min(cap, cur * 2) };
91
+ }
92
+ }
93
+ if (floor) return { ...payload, max_tokens: Math.min(floor, cap || floor) }; // 客户端没设额度:兜底发明一次
94
+ return payload;
95
+ }
96
+ // 空转追问:最后一次空转重试前追加一句 user 兜底(content 空轮大概率是模型“收尾犹豫”,
97
+ // 追问把它逼出一句“任务完成了”即可终结;前面重试仍原样重拉,防改写正常对话)。
98
+ // MSLXDFF_EMPTY_NUDGE=0 关闭;MSLXDFF_EMPTY_NUDGE_TEXT 改话术。
99
+ export function emptyNudgeCfg() {
100
+ const off = process.env.MSLXDFF_EMPTY_NUDGE;
101
+ if (off === "0" || String(off || "").toLowerCase() === "off" || String(off || "").toLowerCase() === "false") return { enabled: false, text: "" };
102
+ const custom = String(process.env.MSLXDFF_EMPTY_NUDGE_TEXT || "").trim();
103
+ return { enabled: true, text: custom || "如果你完成任务了,请简短的说:任务完成了。" };
104
+ }
105
+ export function withEmptyNudge(payload, text) {
106
+ const t = String(text || "").trim();
107
+ if (!t || !payload || !Array.isArray(payload.messages) || !payload.messages.length) return payload;
108
+ const last = payload.messages[payload.messages.length - 1];
109
+ if (last && last.role === "user" && String(last.content || "").includes(t)) return payload;
110
+ return { ...payload, messages: [...payload.messages, { role: "user", content: t }] };
111
+ }
112
+
113
+ // 请求级空轮等待预算:阶梯是「每发候选」独立算的,多候选串起来最坏 = N×(2+8+30),
114
+ // 客户端只是在等一个永远空白的回复。预算封顶「这一次请求最多白等多久」,超限直接收场。
115
+ // MSLXDFF_EMPTY_TURN_BUDGET_MS 覆盖(<=0 或未设 = 默认 45s:够装下一整个阶梯 + 一发换候选)。
116
+ export function emptyTurnBudgetMs() {
117
+ const v = Number(process.env.MSLXDFF_EMPTY_TURN_BUDGET_MS);
118
+ return Number.isFinite(v) && v > 0 ? v : 45_000;
119
+ }
@@ -27,7 +27,7 @@ export function createChatPipeline({ upstream, auto, logs, peers, groups, bus, t
27
27
  mark("parsed");
28
28
  if (aliasInfo) { try { res?.setHeader?.("x-mslxdff-alias", aliasInfo); } catch {} }
29
29
  // mslxdff/ 前缀或 alias 命中时,把 body.model 改写为还原后的模型(与原 gateway 语义一致)
30
- const timeline = { direct: [], peers: [], retries: 0, result: null };
30
+ const timeline = { direct: [], peers: [], retries: 0, retryResult: null, result: null };
31
31
  if (aliasInfo && req?.body && req.body.model !== requested) {
32
32
  req.body = { ...req.body, model: requested };
33
33
  }
@@ -53,6 +53,9 @@ export function createChatPipeline({ upstream, auto, logs, peers, groups, bus, t
53
53
  else timeline.peers.push({ peer: entry.peer, ok: false, status: entry.status, message: entry.message || entry.error || "" });
54
54
  }
55
55
  if (type === "empty-turn-retry") timeline.retries += 1;
56
+ // 空转重试必须交代结局:只记次数的话,"重试后救回来"和"重试到死"在日志里长得一模一样
57
+ if (type === "empty-turn-recovered") timeline.retryResult = "ok";
58
+ if (type === "empty-turn-exhausted") timeline.retryResult = timeline.retryResult || "lost";
56
59
  if (type === "result" || (type === "client-response" && !timeline.result)) {
57
60
  timeline.result = { status: entry.status, detail: entry.detail || null };
58
61
  try { logs?.appendTimeline?.(formatTimeline({ reqId, model: entry.model || requested, ...timeline, totalMs: Date.now() - startedAt })); } catch {}