mslxdff 0.1.152 → 0.1.153
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/package.json
CHANGED
|
@@ -137,10 +137,12 @@ export function createRelayPipeline({
|
|
|
137
137
|
!["tool_calls", "function_call"].includes(_d.sawFinishReason);
|
|
138
138
|
if (_emptyTurn) {
|
|
139
139
|
const _why = _d.sawFinishReason ? ` (finish_reason=${_d.sawFinishReason})` : " (no content, no tool calls)";
|
|
140
|
-
|
|
140
|
+
// 错误包络暂扣后下游不再直观看到上游原文:把摘要带进最终报错(截断 200 字),排障不断线。
|
|
141
|
+
const _err = _d.upstreamErrorText ? ` upstream=${String(_d.upstreamErrorText).slice(0, 200)}` : "";
|
|
142
|
+
try { _logError(actual, 502, `empty turn${_why}${_err}`); } catch {}
|
|
141
143
|
_evt("upstream-error", { reqId, model: actual, status: 502, message: "empty turn", timing: null });
|
|
142
144
|
_evt("fallback", { reqId, from: actual, to: null, reason: "empty turn" });
|
|
143
|
-
return { handled: false, upRes: null, lastErr: { model: actual, upstream: null, status: 502, message: `EMPTY_MODEL_RESPONSE: upstream returned 200 with no content${_why} — retry or rephrase` } };
|
|
145
|
+
return { handled: false, upRes: null, lastErr: { model: actual, upstream: null, status: 502, message: `EMPTY_MODEL_RESPONSE: upstream returned 200 with no content${_why}${_err} — retry or rephrase` } };
|
|
144
146
|
}
|
|
145
147
|
|
|
146
148
|
// 5a. 首块超时未写字节 → 回退(显式 timedOut 字段,status 只是 HTTP 语义展示)
|
package/src/routes/stream.js
CHANGED
|
@@ -30,6 +30,40 @@ function hasPayload(text) {
|
|
|
30
30
|
return false;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// 错误包络 chunk 判定(纯函数):data 行 JSON 含顶层 .error 对象、或只有 finish_reason=error
|
|
34
|
+
// 的空帧,且整 chunk 无任何 content/tool_calls/reasoning 输出 → 返回错误摘要(建议暂扣),
|
|
35
|
+
// 否则返回 null(透传)。解析失败一律透传(默认保安全)。
|
|
36
|
+
// 背景:网关把上游失败包成 HTTP 200 SSE;暂扣后下游流式 UI 不再展示瞬时错误
|
|
37
|
+
//(如 Vertex 503 + google fallback 400),本轮走空转重试,客户端只看到最终结果。
|
|
38
|
+
function holdableChunk(txt) {
|
|
39
|
+
if (typeof txt !== "string" || !txt.includes("data:")) return null;
|
|
40
|
+
let sawData = false;
|
|
41
|
+
const errs = [];
|
|
42
|
+
for (const line of txt.split("\n")) {
|
|
43
|
+
const t = line.trim();
|
|
44
|
+
if (!t.startsWith("data:")) continue;
|
|
45
|
+
const d = t.slice(5).trim();
|
|
46
|
+
if (!d || d === "[DONE]") continue;
|
|
47
|
+
let j = null;
|
|
48
|
+
try { j = JSON.parse(d); } catch { return null; }
|
|
49
|
+
if (!j || typeof j !== "object") return null;
|
|
50
|
+
sawData = true;
|
|
51
|
+
const c0 = (Array.isArray(j.choices) && j.choices[0]) || {};
|
|
52
|
+
const delta = c0.delta || {};
|
|
53
|
+
const msg = c0.message || {};
|
|
54
|
+
const out = delta.content || msg.content || delta.tool_calls || msg.tool_calls || delta.reasoning || msg.reasoning;
|
|
55
|
+
if (out && !(Array.isArray(out) && out.length === 0)) return null;
|
|
56
|
+
if (j.error && typeof j.error === "object") {
|
|
57
|
+
const m = j.error.message || j.error.code || "";
|
|
58
|
+
if (m) errs.push(String(m).slice(0, 300));
|
|
59
|
+
} else if (c0.finish_reason !== "error") {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (!sawData) return null;
|
|
64
|
+
return errs.length ? errs.join(" | ") : "finish_reason=error 空帧";
|
|
65
|
+
}
|
|
66
|
+
|
|
33
67
|
// 自持 reader 优先:for-await 会锁定 ReadableStream,使 body.cancel() 必 reject(真流上等于空操作),
|
|
34
68
|
// 超时/断下游要真能掐上游必须走 reader.cancel()
|
|
35
69
|
async function* bodyChunks(body, reader) {
|
|
@@ -116,6 +150,8 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
|
|
|
116
150
|
chars: 0,
|
|
117
151
|
toolCalls: 0,
|
|
118
152
|
chatShaped: false,
|
|
153
|
+
heldErrorChunks: 0,
|
|
154
|
+
upstreamErrorText: null,
|
|
119
155
|
recoveries: 0,
|
|
120
156
|
};
|
|
121
157
|
let prevChunkAt = t0;
|
|
@@ -206,6 +242,13 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
|
|
|
206
242
|
if (gap > SCORE_STALL_MS) detail.stallHits += 1;
|
|
207
243
|
prevChunkAt = now;
|
|
208
244
|
const txt = chunkText(chunk);
|
|
245
|
+
// 错误包络暂扣:本轮尚未写出真实输出时错误帧不写下游(下游 UI 不再展示瞬时错误),
|
|
246
|
+
// 摘要记 detail.upstreamErrorText 供空转判定与最终报错;已写出真实内容后的错误帧照常透传。
|
|
247
|
+
const holdErr = !wrotePayload ? holdableChunk(txt) : null;
|
|
248
|
+
if (holdErr) {
|
|
249
|
+
detail.heldErrorChunks += 1;
|
|
250
|
+
if (!detail.upstreamErrorText) detail.upstreamErrorText = String(holdErr).slice(0, 500);
|
|
251
|
+
}
|
|
209
252
|
const isPayload = hasPayload(txt);
|
|
210
253
|
try {
|
|
211
254
|
if (txt.includes("[DONE]")) detail.sawDone = true;
|
|
@@ -252,7 +295,7 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
|
|
|
252
295
|
// 首块/空闲超时后上游仍吐出了真实数据 → 只是慢,不是死:撤销超时判定,照常转发
|
|
253
296
|
//(cancel 是异步的,竞态窗口内已到达的数据是纯收益;丢掉是纯损失)
|
|
254
297
|
// 注释帧(keepalive)不算:否则对端只要在发心跳,闸门就永远解除
|
|
255
|
-
if (isPayload && (timedOut || stalled)) {
|
|
298
|
+
if (isPayload && !holdErr && (timedOut || stalled)) {
|
|
256
299
|
timedOut = false;
|
|
257
300
|
stalled = false;
|
|
258
301
|
detail.recoveries = (detail.recoveries || 0) + 1;
|
|
@@ -260,7 +303,7 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
|
|
|
260
303
|
if (firstTimer) { clearTimeout(firstTimer); firstTimer = null; }
|
|
261
304
|
}
|
|
262
305
|
if (timedOut || stalled || tooLong) break;
|
|
263
|
-
if (isPayload && first) {
|
|
306
|
+
if (isPayload && first && !holdErr) {
|
|
264
307
|
first = false;
|
|
265
308
|
ttf = Math.round(now - t0);
|
|
266
309
|
onFirstChunk?.(ttf);
|
|
@@ -279,11 +322,13 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
|
|
|
279
322
|
}
|
|
280
323
|
} catch {}
|
|
281
324
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
325
|
+
if (!holdErr) {
|
|
326
|
+
wroteAny = true;
|
|
327
|
+
if (isPayload) wrotePayload = true;
|
|
328
|
+
detail.wroteChunks += 1;
|
|
329
|
+
detail.wroteBytes += Buffer.isBuffer(outChunk) ? outChunk.length : (outChunk?.length ?? len);
|
|
330
|
+
try { res.write(outChunk); } catch { /* 下游已断开:onClose 已掐上游 */ }
|
|
331
|
+
}
|
|
287
332
|
armStall();
|
|
288
333
|
}
|
|
289
334
|
if (!detail.exitReason) detail.exitReason = detail.downstreamClosed ? "downstream-closed" : "normal";
|