mslxdff 0.1.155 → 0.1.156
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 +1 -1
- package/src/cli/commands/model/list-providers.js +3 -2
- package/src/cli/commands/model/list-render.js +7 -9
- package/src/cli/commands/model/list-sort.js +52 -0
- package/src/cli/commands/model/list-tty.js +76 -0
- package/src/cli/commands/model/list.js +5 -62
- package/src/model-trace.js +1 -0
- package/src/providers/qoder/chat.js +76 -2
- package/src/providers/qoder/index.js +2 -1
- package/src/providers/qoder/sse.js +19 -3
- package/src/providers/qoder/sticky.js +1 -0
- package/src/providers/qoder/stream.js +32 -9
- package/src/routes/chat/relay-pipeline.js +5 -1
- package/src/routes/stream.js +44 -5
package/package.json
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 其他供应商 allowlist 段 + 遗留别名映射展示。
|
|
3
3
|
* 输入均为已算好的数据;state/model-id 走动态 import(与原内联一致,避免循环依赖)。
|
|
4
|
+
* allowlist 按 compareModelIds 排序展示(只改展示顺序,不改 allowlist 落盘内容)。
|
|
4
5
|
*/
|
|
6
|
+
import { compareModelIds } from "./list-sort.js";
|
|
5
7
|
export async function renderOtherProviders({ pickedIds, ids, fullAliases }) {
|
|
6
8
|
const { loadProviderConfigs, loadProviderAllowedModels, loadProviderAllowAnyModels, loadProviderBaseUrl } = await import("../../../state.js");
|
|
7
9
|
const { loadModelAliases: _la2, getAliasForModel: _gaf } = await import("../../../providers/model-id.js");
|
|
@@ -38,8 +40,7 @@ export async function renderOtherProviders({ pickedIds, ids, fullAliases }) {
|
|
|
38
40
|
console.log(` 或放行全部: mslxdff -provider ${pid} allowAny on`);
|
|
39
41
|
}
|
|
40
42
|
} else {
|
|
41
|
-
for (const
|
|
42
|
-
const canonical = `${pid}/${raw}`;
|
|
43
|
+
for (const canonical of allowed.map((raw) => `${pid}/${raw}`).sort(compareModelIds)) {
|
|
43
44
|
let alias = null;
|
|
44
45
|
try { alias = _gaf(canonical); } catch {}
|
|
45
46
|
if (!alias && String(canonical).includes("/")) alias = String(canonical).replace(/\//g, "-");
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
+
import { compareModelIds, providerOf, providerRank } from "./list-sort.js";
|
|
4
5
|
|
|
5
6
|
/** 构建 id → 别名映射(原 list 两处重复逻辑收敛) */
|
|
6
7
|
export function buildAliasMap(ids, getAliasForModel) {
|
|
@@ -57,22 +58,19 @@ export function renderFreeList({ ids, at, pickedIds, sortedProvs, groups, aliasM
|
|
|
57
58
|
}
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
/** 按供应商分组 + 排序(opencode
|
|
61
|
+
/** 按供应商分组 + 排序(opencode 段最前,已知供应商优先,组内字母序) */
|
|
61
62
|
export function groupByProvider(ids) {
|
|
62
63
|
const groups = {};
|
|
63
64
|
for (const id of ids) {
|
|
64
|
-
const prov =
|
|
65
|
+
const prov = providerOf(id);
|
|
65
66
|
if (!groups[prov]) groups[prov] = [];
|
|
66
67
|
groups[prov].push(id);
|
|
67
68
|
}
|
|
68
|
-
const
|
|
69
|
+
for (const key of Object.keys(groups)) groups[key].sort(compareModelIds);
|
|
69
70
|
const sortedProvs = Object.keys(groups).sort((a, b) => {
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (ib === -1) return -1;
|
|
74
|
-
return ia - ib;
|
|
75
|
-
}
|
|
71
|
+
const ra = providerRank(a);
|
|
72
|
+
const rb = providerRank(b);
|
|
73
|
+
if (ra !== rb) return ra - rb;
|
|
76
74
|
return a.localeCompare(b);
|
|
77
75
|
});
|
|
78
76
|
return { groups, sortedProvs };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// `-models` / `-model list` 展示排序:opencode free 优先 → 其余供应商分组 → 组内字母序。
|
|
2
|
+
// 单一来源:本文件被 list.js(交互候选)与 list-render.js(分组渲染)共用。
|
|
3
|
+
// Note: 排序只影响展示顺序,不改 picks/候选集合 — 见 .agents/notes/implemented/feature/2026-09-28-models-sort-opencode-free-first.md
|
|
4
|
+
import { isFreeModel } from "../../../models.js";
|
|
5
|
+
|
|
6
|
+
/** 供应商展示优先级(opencode 恒第一;未知供应商按字母序排其后) */
|
|
7
|
+
export const PROVIDER_ORDER = ["opencode", "workbuddy", "cline", "openrouter", "qoder", "codearts", "traework", "bai"];
|
|
8
|
+
|
|
9
|
+
/** 取模型 id 的供应商前缀;无斜杠的裸 id 归 opencode */
|
|
10
|
+
export function providerOf(id) {
|
|
11
|
+
const s = String(id || "");
|
|
12
|
+
const i = s.indexOf("/");
|
|
13
|
+
return i > 0 ? s.slice(0, i).toLowerCase() : "opencode";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 供应商排序权重:opencode=0,已知供应商=其序号+1,未知=大数(按字母序兜底) */
|
|
17
|
+
export function providerRank(prov) {
|
|
18
|
+
const i = PROVIDER_ORDER.indexOf(prov);
|
|
19
|
+
return i === -1 ? PROVIDER_ORDER.length : i;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 模型 id 排序比较器:
|
|
24
|
+
* 1) opencode free(isFreeModel)最前 —— 用户主诉求
|
|
25
|
+
* 2) opencode 非 free 次之
|
|
26
|
+
* 3) 其余供应商按 PROVIDER_ORDER,未知按字母序
|
|
27
|
+
* 4) 同段内 localeCompare 字母序(大小写不敏感兜底)
|
|
28
|
+
*/
|
|
29
|
+
export function compareModelIds(a, b) {
|
|
30
|
+
const pa = providerOf(a);
|
|
31
|
+
const pb = providerOf(b);
|
|
32
|
+
const fa = pa === "opencode" && isFreeModel(a);
|
|
33
|
+
const fb = pb === "opencode" && isFreeModel(b);
|
|
34
|
+
// 段位:0=opencode free,1=opencode 其他,2=其他供应商
|
|
35
|
+
const ga = fa ? 0 : pa === "opencode" ? 1 : 2;
|
|
36
|
+
const gb = fb ? 0 : pb === "opencode" ? 1 : 2;
|
|
37
|
+
if (ga !== gb) return ga - gb;
|
|
38
|
+
if (ga === 2) {
|
|
39
|
+
const ra = providerRank(pa);
|
|
40
|
+
const rb = providerRank(pb);
|
|
41
|
+
if (ra !== rb) return ra - rb;
|
|
42
|
+
if (pa !== pb) return pa.localeCompare(pb);
|
|
43
|
+
}
|
|
44
|
+
const la = String(a).toLowerCase();
|
|
45
|
+
const lb = String(b).toLowerCase();
|
|
46
|
+
return la === lb ? String(a).localeCompare(String(b)) : la.localeCompare(lb);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 返回排序后的新数组(不改原数组) */
|
|
50
|
+
export function sortModelIds(ids) {
|
|
51
|
+
return [...(ids || [])].sort(compareModelIds);
|
|
52
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// 交互式 `-models`(TTY 多选)主流程:从 list.js 拆出以守住 ≤10KB 体积门。
|
|
2
|
+
// 候选合并(allowlist + live + picks 孤儿)→ 展示排序 → 勾选落盘。
|
|
3
|
+
// Note: 排序规则单一来源在 list-sort.js;候选集合语义不变(只改展示顺序)。
|
|
4
|
+
import { loadModelErrors, loadModelPicks, saveModelPicks } from "../../../state.js";
|
|
5
|
+
import { getPreferredModel } from "../../../auto.js";
|
|
6
|
+
import { pickInteractiveMulti } from "../../interactive.js";
|
|
7
|
+
import { filterStalePicks } from "../../../providers/model-id.js";
|
|
8
|
+
import { mergeLiveIds } from "./list-live.js";
|
|
9
|
+
import { sortModelIds } from "./list-sort.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 拼交互候选:opencode live ids + 已启用 provider 的 allowlist + live 补齐 + 可见 picks。
|
|
13
|
+
* 返回 { items, pickedIds };items 已按 sortModelIds 排序。
|
|
14
|
+
*/
|
|
15
|
+
export async function buildPickerItems(liveIds, pickedIds) {
|
|
16
|
+
const statuses = loadModelErrors();
|
|
17
|
+
const current = getPreferredModel();
|
|
18
|
+
const combinedIds = [...liveIds];
|
|
19
|
+
const seen = new Set(combinedIds);
|
|
20
|
+
let staleFilter = null;
|
|
21
|
+
try {
|
|
22
|
+
const { loadProviderConfigs, loadProviderAllowedModels } = await import("../../../state.js");
|
|
23
|
+
const { buildProviderRows } = await import("../../provider-row.js");
|
|
24
|
+
// 已知 = 已启用(与 -provider list 同一 enabled 口径:有 baseUrl 且有 key;
|
|
25
|
+
// openrouter 有 key 即算,opencode 内置恒启用)。未启用 provider 的 picks
|
|
26
|
+
// 不进列表,启用后自动回来(数据未动)。
|
|
27
|
+
const knownProviders = buildProviderRows({}).filter((r) => r.enabled).map((r) => r.id);
|
|
28
|
+
loadProviderConfigs();
|
|
29
|
+
const allowedIds = [];
|
|
30
|
+
for (const pid of knownProviders.filter((k) => String(k).toLowerCase() !== "opencode")) {
|
|
31
|
+
for (const raw of loadProviderAllowedModels(pid)) {
|
|
32
|
+
const canonical = `${pid}/${raw}`;
|
|
33
|
+
allowedIds.push(canonical);
|
|
34
|
+
if (!seen.has(canonical)) {
|
|
35
|
+
seen.add(canonical);
|
|
36
|
+
combinedIds.push(canonical);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
await mergeLiveIds({ combinedIds, seen, knownProviders, liveIds });
|
|
41
|
+
staleFilter = { knownProviders, liveIds, allowedIds };
|
|
42
|
+
} catch {}
|
|
43
|
+
// provider 已不存在的 picks 孤儿不进交互列表(数据不动,status --all 仍可审计)。
|
|
44
|
+
// configs 读失败时 staleFilter 为空 → 不过滤(默认放行)。
|
|
45
|
+
const visiblePicks = staleFilter ? filterStalePicks(pickedIds, staleFilter) : pickedIds;
|
|
46
|
+
for (const pid of visiblePicks) {
|
|
47
|
+
if (!seen.has(pid)) {
|
|
48
|
+
seen.add(pid);
|
|
49
|
+
combinedIds.push(pid);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// 展示排序:opencode free 最前 → opencode 其他 → 各供应商分组,组内字母序。
|
|
53
|
+
const items = sortModelIds(combinedIds).map((id) => {
|
|
54
|
+
const e = statuses[id];
|
|
55
|
+
return {
|
|
56
|
+
id,
|
|
57
|
+
status: typeof e === "number" ? "error" : e?.status || "normal",
|
|
58
|
+
current: id === current,
|
|
59
|
+
picked: pickedIds.includes(id),
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
return { items, pickedIds };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 跑一轮 TTY 勾选并落盘 picks(取消则不写)。调用方负责 exit。 */
|
|
66
|
+
export async function runInteractivePick(liveIds) {
|
|
67
|
+
const pickedIds = loadModelPicks();
|
|
68
|
+
const { items } = await buildPickerItems(liveIds, pickedIds);
|
|
69
|
+
const result = await pickInteractiveMulti(items, new Set(pickedIds), Math.max(0, items.findIndex((x) => x.current)));
|
|
70
|
+
if (!result) {
|
|
71
|
+
console.log("cancelled — picks unchanged");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
saveModelPicks([...result]);
|
|
75
|
+
console.log(`saved ${result.size} picked model(s): ${[...result].join(", ") || "(none — auto uses full list)"}`);
|
|
76
|
+
}
|
|
@@ -2,16 +2,12 @@ import { join } from "node:path";
|
|
|
2
2
|
import { createModelsService } from "../../../models.js";
|
|
3
3
|
import { createUpstreamClient } from "../../../upstream.js";
|
|
4
4
|
import { logDir } from "../../../logs.js";
|
|
5
|
-
import {
|
|
6
|
-
import { getPreferredModel } from "../../../auto.js";
|
|
5
|
+
import { loadModelPicks } from "../../../state.js";
|
|
7
6
|
import { fmtShanghaiYMDHM } from "../../../time.js";
|
|
8
7
|
import { readModelsCache } from "../../util.js";
|
|
9
|
-
import { pickInteractiveMulti } from "../../interactive.js";
|
|
10
8
|
import { buildAliasMap, readFullAliases, renderProviderList, renderFreeList, groupByProvider } from "./list-render.js";
|
|
11
9
|
import { renderOtherProviders } from "./list-providers.js";
|
|
12
|
-
import {
|
|
13
|
-
import { mergeLiveIds } from "./list-live.js";
|
|
14
|
-
|
|
10
|
+
import { compareModelIds } from "./list-sort.js";
|
|
15
11
|
/**
|
|
16
12
|
* `-model list` 全流程:参数解析 → 刷新/回退 → provider 分支 → TTY 交互 → 分组渲染。
|
|
17
13
|
* 渲染下沉 list-render / list-providers,本文件只留流程编排。
|
|
@@ -104,8 +100,7 @@ export async function handleModelList(args, idx, sub) {
|
|
|
104
100
|
const at2 = cachedAt ? ` (cached ${fmtShanghaiYMDHM(cachedAt)})` : "";
|
|
105
101
|
console.log(`${allowed.length} model(s) for ${prov}${at2} (allowlist,原名 + 别名):`);
|
|
106
102
|
const pickedIds2 = loadModelPicks();
|
|
107
|
-
for (const
|
|
108
|
-
const canonical = `${prov}/${raw}`;
|
|
103
|
+
for (const canonical of allowed.map((raw) => `${prov}/${raw}`).sort(compareModelIds)) {
|
|
109
104
|
let alias = null;
|
|
110
105
|
try { alias = getAliasForModel(canonical); } catch {}
|
|
111
106
|
if (!alias && String(canonical).includes("/")) alias = String(canonical).replace(/\//g, "-");
|
|
@@ -134,60 +129,8 @@ export async function handleModelList(args, idx, sub) {
|
|
|
134
129
|
process.exit(0);
|
|
135
130
|
}
|
|
136
131
|
if (sub === undefined && process.stdin.isTTY && process.stdout.isTTY) {
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
const pickedIds = loadModelPicks();
|
|
140
|
-
const combinedIds = [...ids];
|
|
141
|
-
const seen = new Set(combinedIds);
|
|
142
|
-
let staleFilter = null;
|
|
143
|
-
try {
|
|
144
|
-
const { loadProviderConfigs, loadProviderAllowedModels } = await import("../../../state.js");
|
|
145
|
-
const { buildProviderRows } = await import("../../provider-row.js");
|
|
146
|
-
// 已知 = 已启用(与 -provider list 同一 enabled 口径:有 baseUrl 且有 key;
|
|
147
|
-
// openrouter 有 key 即算,opencode 内置恒启用)。未启用 provider 的 picks
|
|
148
|
-
// 不进列表,启用后自动回来(数据未动)。
|
|
149
|
-
const knownProviders = buildProviderRows({}).filter((r) => r.enabled).map((r) => r.id);
|
|
150
|
-
const configs = loadProviderConfigs();
|
|
151
|
-
const allowedIds = [];
|
|
152
|
-
for (const pid of knownProviders.filter((k) => String(k).toLowerCase() !== "opencode")) {
|
|
153
|
-
const allowed = loadProviderAllowedModels(pid);
|
|
154
|
-
for (const raw of allowed) {
|
|
155
|
-
const canonical = `${pid}/${raw}`;
|
|
156
|
-
allowedIds.push(canonical);
|
|
157
|
-
if (!seen.has(canonical)) {
|
|
158
|
-
seen.add(canonical);
|
|
159
|
-
combinedIds.push(canonical);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
await mergeLiveIds({ combinedIds, seen, knownProviders, liveIds: ids });
|
|
164
|
-
staleFilter = { knownProviders, liveIds: ids, allowedIds };
|
|
165
|
-
} catch {}
|
|
166
|
-
// provider 已不存在的 picks 孤儿不进交互列表(数据不动,status --all 仍可审计)。
|
|
167
|
-
// configs 读失败时 staleFilter 为空 → 不过滤(默认放行)。
|
|
168
|
-
const visiblePicks = staleFilter ? filterStalePicks(pickedIds, staleFilter) : pickedIds;
|
|
169
|
-
for (const pid of visiblePicks) {
|
|
170
|
-
if (!seen.has(pid)) {
|
|
171
|
-
seen.add(pid);
|
|
172
|
-
combinedIds.push(pid);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
const items = combinedIds.map((id) => {
|
|
176
|
-
const e = statuses[id];
|
|
177
|
-
return {
|
|
178
|
-
id,
|
|
179
|
-
status: typeof e === "number" ? "error" : e?.status || "normal",
|
|
180
|
-
current: id === current,
|
|
181
|
-
picked: pickedIds.includes(id),
|
|
182
|
-
};
|
|
183
|
-
});
|
|
184
|
-
const result = await pickInteractiveMulti(items, new Set(pickedIds), Math.max(0, items.findIndex((x) => x.current)));
|
|
185
|
-
if (!result) {
|
|
186
|
-
console.log("cancelled — picks unchanged");
|
|
187
|
-
process.exit(0);
|
|
188
|
-
}
|
|
189
|
-
saveModelPicks([...result]);
|
|
190
|
-
console.log(`saved ${result.size} picked model(s): ${[...result].join(", ") || "(none — auto uses full list)"}`);
|
|
132
|
+
const { runInteractivePick } = await import("./list-tty.js");
|
|
133
|
+
await runInteractivePick(ids);
|
|
191
134
|
process.exit(0);
|
|
192
135
|
}
|
|
193
136
|
const at = cachedAt ? ` (cached ${fmtShanghaiYMDHM(cachedAt)})` : "";
|
package/src/model-trace.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Per-model request trace formatting. Never include prompt, response body, headers or credentials.
|
|
2
2
|
// Note: 同步 appendFileSync(异步 append 会在并发下打乱同一请求的阶段行序)— 见 .agents/notes/implemented/feature/2026-09-25-model-trace-log.md
|
|
3
|
+
// Note: 事件面用黑名单(默认全可见,只排除噪声/敏感面)+ 决定类事件只渲染登记过的标量字段——起因是 empty-turn-retry 曾被阶段白名单静默吞掉,"关键决定不许再被漏登记" — 见 .agents/notes/implemented/feature/2026-09-27-qoder-per-request-sticky-account.md
|
|
3
4
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
4
5
|
import { join } from "node:path";
|
|
5
6
|
import { logDir } from "./logs.js";
|
|
@@ -5,7 +5,7 @@ import { normalizeRegion } from "./constants.js";
|
|
|
5
5
|
import { buildUpstreamRequest } from "./request.js";
|
|
6
6
|
import { reshapeQoderStream } from "./stream.js";
|
|
7
7
|
import { aggregateQoderStream, toCompletionJson } from "./aggregate.js";
|
|
8
|
-
import { errorStatus } from "./sse.js";
|
|
8
|
+
import { errorStatus, extractDelta } from "./sse.js";
|
|
9
9
|
|
|
10
10
|
function hostOf(url) {
|
|
11
11
|
try { return new URL(String(url)).host; } catch { return "-"; }
|
|
@@ -19,6 +19,62 @@ function mapUpstreamError(status, detail) {
|
|
|
19
19
|
return { kind: status === 401 || status === 403 ? "auth" : "upstream", status, detail: String(detail || "").slice(0, 300) };
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// 预读上限:判决恒在首帧(实测 457 字节),8 帧足够覆盖"角色帧先到、判决帧随后",
|
|
23
|
+
// 又不会把首字节延迟拖到不可控。超窗未得判决即放弃(退化为无冷却的流内 error,见 SPEC 已知上限)。
|
|
24
|
+
const PEEK_MAX_FRAMES = 8;
|
|
25
|
+
|
|
26
|
+
// 预读上游首帧只为取「流内判决」:qoder 把限流/鉴权判决裹在 HTTP 200 的 SSE 信封里
|
|
27
|
+
//(statusCodeValue:403 + serviceAvailable:false),upRes.status 恒 200,判决只有读完帧才知道。
|
|
28
|
+
// 不预读则状态码永远出不来流式路径 → 门面不冷却该号(ADR-0036 的粘号会把重试继续粘在它上面)。
|
|
29
|
+
// 返回 { res, status, frames }:res 是把预读帧回灌后的等价上游响应,frames 交回灌用。
|
|
30
|
+
// 任何异常都降级为「无判决」(status=0):排障手段不得变成故障源。
|
|
31
|
+
async function peekEnvelopeVerdict(upRes, cdbg) {
|
|
32
|
+
if (!upRes?.body) return { res: upRes, status: 0, frames: [] };
|
|
33
|
+
let reader;
|
|
34
|
+
try { reader = upRes.body.getReader(); } catch { return { res: upRes, status: 0, frames: [] }; }
|
|
35
|
+
const dec = new TextDecoder();
|
|
36
|
+
const frames = [];
|
|
37
|
+
let text = "";
|
|
38
|
+
let status = 0;
|
|
39
|
+
let decided = false;
|
|
40
|
+
try {
|
|
41
|
+
for (let i = 0; i < PEEK_MAX_FRAMES && !decided; i++) {
|
|
42
|
+
const { done, value } = await reader.read();
|
|
43
|
+
if (done) break;
|
|
44
|
+
if (value) { frames.push(value); text += dec.decode(value, { stream: true }); }
|
|
45
|
+
// 逐行判定:先到判决(err)或先到有效载荷即停,避免无谓等待
|
|
46
|
+
for (const raw of text.split("\n")) {
|
|
47
|
+
const line = raw.trim();
|
|
48
|
+
if (!line.startsWith("data:")) continue;
|
|
49
|
+
const p = line.slice(5).trim();
|
|
50
|
+
if (!p || p === "[DONE]") continue;
|
|
51
|
+
const d = extractDelta(p);
|
|
52
|
+
if (d.err) { status = Number(d.err.status) || 0; decided = true; break; }
|
|
53
|
+
if (d.content || d.reasoning || d.toolCalls || d.usageIn || d.usageOut) { decided = true; break; }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} catch (e) {
|
|
57
|
+
cdbg(`[peek-throw] ${String(e?.message || e).slice(0, 160)}`);
|
|
58
|
+
}
|
|
59
|
+
cdbg(`[peek] frames=${frames.length} verdict=${status || "none(正常流)"}`);
|
|
60
|
+
// 回灌:已读帧先按原样吐出,再接续同一个 reader 的剩余部分
|
|
61
|
+
const body = new ReadableStream({
|
|
62
|
+
start(ctrl) {
|
|
63
|
+
for (const f of frames) { try { ctrl.enqueue(f); } catch {} }
|
|
64
|
+
(async () => {
|
|
65
|
+
try {
|
|
66
|
+
for (;;) {
|
|
67
|
+
const { done, value } = await reader.read();
|
|
68
|
+
if (done) break;
|
|
69
|
+
if (value) ctrl.enqueue(value);
|
|
70
|
+
}
|
|
71
|
+
} catch {}
|
|
72
|
+
try { ctrl.close(); } catch {}
|
|
73
|
+
})();
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
return { res: new Response(body, { status: upRes.status, headers: upRes.headers }), status, frames };
|
|
77
|
+
}
|
|
22
78
|
export function createChatService({ id = "qoder", fetchImpl, timeoutMs } = {}) {
|
|
23
79
|
if (!fetchImpl) fetchImpl = compatFetch;
|
|
24
80
|
|
|
@@ -38,6 +94,7 @@ export function createChatService({ id = "qoder", fetchImpl, timeoutMs } = {}) {
|
|
|
38
94
|
// 上游状态回显(内部交接用,不落日志):流式路径按对外契约把非 200 整形成 "200 + 流内 error",
|
|
39
95
|
// 真实状态码只能靠这个头带出——否则 401/403/429/5xx 到不了 provider 门面,坏号不被冷却,
|
|
40
96
|
// 而"同请求粘号"(ADR-0036)会把后续重试继续粘在这个坏号上。
|
|
97
|
+
// Note: 流内判决需预读首帧才能拿到(HTTP 200 里裹 statusCodeValue)— 见 .agents/notes/implemented/bug-fix/2026-09-27-qoder-envelope-verdict-cooldown.md
|
|
41
98
|
const withUpstreamStatus = (res, st) => {
|
|
42
99
|
try { res.headers.set("x-mslxdff-qoder-upstream-status", String(st)); } catch {}
|
|
43
100
|
return res;
|
|
@@ -53,23 +110,32 @@ export function createChatService({ id = "qoder", fetchImpl, timeoutMs } = {}) {
|
|
|
53
110
|
|
|
54
111
|
let upRes; let reqUrl = "";
|
|
55
112
|
const echo = (res) => withEcho(res, reqUrl, region, pick);
|
|
113
|
+
// 排障日志(QODER_DEBUG_STREAM=1 开):记录出口/状态/耗时/响应头,便于对比"200 但空轮"
|
|
114
|
+
const cdbg = (...a) => { if (process.env.QODER_DEBUG_STREAM === "1") console.log("[qoder-chat]", ...a); };
|
|
115
|
+
let upMs = 0;
|
|
56
116
|
try {
|
|
57
117
|
const req = buildUpstreamRequest({ sess, region: normalizeRegion(region), model, messages, tools, maxTokens });
|
|
58
118
|
reqUrl = req.url;
|
|
119
|
+
cdbg(`[req] url=${hostOf(req.url)} region=${normalizeRegion(region)} model=${model} msgs=${messages.length} tools=${tools?.length ?? 0} maxTokens=${maxTokens} stream=${stream} bodyBytes=${req.bodyStr?.length ?? 0}`);
|
|
120
|
+
const t0 = Date.now();
|
|
59
121
|
upRes = await fetchImpl(req.url, {
|
|
60
122
|
method: "POST",
|
|
61
123
|
headers: req.headers,
|
|
62
124
|
body: req.bodyStr,
|
|
63
125
|
signal: timeoutSignal(timeoutMs || 120_000),
|
|
64
126
|
});
|
|
127
|
+
upMs = Date.now() - t0;
|
|
128
|
+
cdbg(`[res] status=${upRes.status} ms=${upMs} ct=${upRes.headers.get("content-type")} host=${hostOf(reqUrl)}`);
|
|
65
129
|
} catch (e) {
|
|
66
130
|
const detail = String(e?.message || e).slice(0, 300);
|
|
131
|
+
cdbg(`[res-throw] ms=${upMs} err=${detail}`);
|
|
67
132
|
// 网络异常(超时/连接失败)折成 502:门面据此冷却该号(否则坏号恒在轮换池里)
|
|
68
133
|
if (stream) return withUpstreamStatus(echo(reshapeQoderStream(new Response("data: [DONE]\n\n"), { model, chatId })), 502);
|
|
69
134
|
return withUpstreamStatus(echo(errRes(502, detail)), 502);
|
|
70
135
|
}
|
|
71
136
|
if (upRes.status !== 200) {
|
|
72
137
|
const detail = await upRes.text().catch(() => "");
|
|
138
|
+
cdbg(`[res-non200] status=${upRes.status} body=${detail.slice(0, 300)}`);
|
|
73
139
|
const e = mapUpstreamError(upRes.status, detail);
|
|
74
140
|
if (stream) {
|
|
75
141
|
// 首包错误也走流内 error 事件(与旧 reshapeStream catch 语义一致:200 + event:error + DONE)
|
|
@@ -86,7 +152,15 @@ export function createChatService({ id = "qoder", fetchImpl, timeoutMs } = {}) {
|
|
|
86
152
|
}
|
|
87
153
|
return withUpstreamStatus(echo(errRes(errorStatus(e), e.detail, e.kind)), upRes.status);
|
|
88
154
|
}
|
|
89
|
-
if (stream)
|
|
155
|
+
if (stream) {
|
|
156
|
+
// 预读首帧取流内判决(qoder 把 403/429 裹在 200 的信封里),预读帧原样回灌给整形器。
|
|
157
|
+
// 判决状态码经 withUpstreamStatus 出门面 → index.js 据此 ring.onError 冷却该号 →
|
|
158
|
+
// 粘号选择器下次因 isCooling 为真而换号。对外仍恒 200(契约不变)。
|
|
159
|
+
const peek = await peekEnvelopeVerdict(upRes, cdbg);
|
|
160
|
+
const shaped = echo(reshapeQoderStream(peek.res, { model, chatId, prefetched: peek.frames }));
|
|
161
|
+
// 只在真有判决时挂状态码:正常流不写这个头(index.js 靠它区分"要不要冷却")
|
|
162
|
+
return peek.status ? withUpstreamStatus(shaped, peek.status) : shaped;
|
|
163
|
+
}
|
|
90
164
|
try {
|
|
91
165
|
const agg = await aggregateQoderStream(upRes);
|
|
92
166
|
const out = toCompletionJson({ model, chatId, ...agg });
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// qoder provider 门面(原生直连,零桥依赖):
|
|
2
2
|
// 多号 = keys[] 里每条 device_token blob(auths/qoder-<uid>.json 落盘由 login 维护);
|
|
3
|
-
//
|
|
3
|
+
// 选号 → 建会话 → COSY 签名直调上游;恒 local-only 不借 key。
|
|
4
|
+
// Note: 同请求粘号(一次客户端请求内复用同一个号,仅 401/403/429/5xx 冷却才换)与流式坏号冷却(真实状态码经内部头带出)— 见 .agents/notes/implemented/feature/2026-09-27-qoder-per-request-sticky-account.md
|
|
4
5
|
import { loadProviderKeys, loadProviderConfig } from "../../state.js";
|
|
5
6
|
import { listAccountDocs } from "./account-store.js";
|
|
6
7
|
import { defaultStateFile } from "../../state/store.js";
|
|
@@ -1,17 +1,30 @@
|
|
|
1
1
|
// qoder 上游 SSE 信封解析(转译 qoder2api bridge.go ExtractDelta)
|
|
2
2
|
// 帧形态:data:{"headers":{},"body":"<内层JSON>","statusCodeValue":200}
|
|
3
3
|
// 信封 statusCodeValue!=200 = 瞬时上游错;内层 code!=0 = 业务错;usage 同帧合并
|
|
4
|
+
// 排障日志(QODER_DEBUG_STREAM=1 开):静默吞帧的分支必须有痕,否则"内容凭空消失"无从追
|
|
5
|
+
const DBG_SSE = () => process.env.QODER_DEBUG_STREAM === "1";
|
|
6
|
+
const ddbg = (...a) => { if (DBG_SSE()) console.log("[qoder-sse]", ...a); };
|
|
7
|
+
|
|
4
8
|
export function extractDelta(dataLine) {
|
|
5
9
|
let wrapper;
|
|
6
|
-
try { wrapper = JSON.parse(dataLine); } catch
|
|
10
|
+
try { wrapper = JSON.parse(dataLine); } catch (e) {
|
|
11
|
+
ddbg(`[envelope-parse-fail] ${e.message} line=${dataLine.slice(0, 300)}`);
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
7
14
|
if (wrapper.statusCodeValue !== undefined && Number(wrapper.statusCodeValue) !== 200) {
|
|
8
15
|
const detail = typeof wrapper.body === "string" && wrapper.body ? wrapper.body : JSON.stringify(wrapper).slice(0, 400);
|
|
9
16
|
return { err: { kind: "upstream", status: Number(wrapper.statusCodeValue) || 502, detail } };
|
|
10
17
|
}
|
|
11
18
|
const inner = wrapper.body;
|
|
12
|
-
if (!inner || typeof inner !== "string")
|
|
19
|
+
if (!inner || typeof inner !== "string") {
|
|
20
|
+
ddbg(`[body-not-string] bodyType=${typeof inner} envelopeKeys=${Object.keys(wrapper).join(",")} envelope=${dataLine.slice(0, 300)}`);
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
13
23
|
let j;
|
|
14
|
-
try { j = JSON.parse(inner); } catch
|
|
24
|
+
try { j = JSON.parse(inner); } catch (e) {
|
|
25
|
+
ddbg(`[inner-parse-fail] ${e.message} body=${inner.slice(0, 300)}`);
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
15
28
|
let usageIn = 0, usageOut = 0;
|
|
16
29
|
if (j.usage && typeof j.usage === "object") {
|
|
17
30
|
usageIn = Math.trunc(Number(j.usage.prompt_tokens) || 0);
|
|
@@ -33,6 +46,9 @@ export function extractDelta(dataLine) {
|
|
|
33
46
|
return { err: { kind: /inappropriate|DataInspection|Sensitive|ContentFilter/i.test(String(j.message)) ? "content_policy" : "business", code: j.code, detail: String(j.message || "") } };
|
|
34
47
|
}
|
|
35
48
|
if (usageIn > 0 || usageOut > 0) return { usageIn, usageOut };
|
|
49
|
+
// 走到这里 = 帧既无 content/tool_calls、也无业务错、也无 usage。空轮排查的关键取证点:
|
|
50
|
+
// 把内层 JSON 的顶层键与 choices 原样打出来,一眼看出上游改了什么字段名。
|
|
51
|
+
ddbg(`[no-delta] innerKeys=${Object.keys(j).join(",")} choicesLen=${choices.length} inner=${JSON.stringify(j).slice(0, 400)}`);
|
|
36
52
|
return {};
|
|
37
53
|
}
|
|
38
54
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// 背景:keyring 每次 next() 都前进一格,而 qoder 两个号分属 cn/global 两区(region 决定 URL),
|
|
4
4
|
// 于是「重试」会把同一个请求甩到另一个区——排障时表现为"URL 莫名在切"。粘号把"切号"收回到
|
|
5
5
|
// 唯一正当理由:该号已被冷却。无 scope(拿不到请求标识)时退化为原 round-robin,与旧行为一致。
|
|
6
|
+
// Note: 切号的唯一正当理由是冷却(401/403/429/5xx)——空转等其它失败不换号,故选择器要能问 keyring "上次这个号还在冷却吗" — 见 .agents/notes/implemented/feature/2026-09-27-qoder-per-request-sticky-account.md
|
|
6
7
|
export function createStickyPicker({ pick, isCooling = () => false, now = Date.now, ttlMs = 600_000, maxEntries = 200 } = {}) {
|
|
7
8
|
const seen = new Map(); // scope → { key, at }
|
|
8
9
|
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
// usage 只在尾帧出现:收到即记,附在 finish chunk 上一次发出(与旧回放语义一致)。
|
|
4
4
|
import { extractDelta } from "./sse.js";
|
|
5
5
|
|
|
6
|
+
// 空轮排障用的临时日志开关:QODER_DEBUG_STREAM=1 才输出,默认静默(问题定位后可摘)
|
|
7
|
+
const DBG_STREAM = () => process.env.QODER_DEBUG_STREAM === "1";
|
|
8
|
+
const sdbg = (...a) => { if (DBG_STREAM()) console.log("[qoder-stream]", ...a); };
|
|
6
9
|
function openaiChunk({ model, chatId, delta, finish, usage }) {
|
|
7
10
|
const c = {
|
|
8
11
|
id: chatId,
|
|
@@ -19,11 +22,13 @@ function errorEvent(e) {
|
|
|
19
22
|
return `event: error\ndata: ${JSON.stringify({ message: String(e?.detail || e?.message || e), type: e?.kind || "upstream_error" })}\n\n`;
|
|
20
23
|
}
|
|
21
24
|
|
|
22
|
-
export function reshapeQoderStream(upstreamRes, { model, chatId }) {
|
|
25
|
+
export function reshapeQoderStream(upstreamRes, { model, chatId, prefetched = [] }) {
|
|
23
26
|
const src = upstreamRes.body;
|
|
24
27
|
const enc = new TextEncoder();
|
|
25
28
|
const dec = new TextDecoder();
|
|
26
|
-
|
|
29
|
+
// 预读帧(chat.js 为取流内判决而先读的字节)原样回灌:既不丢内容也不重复,
|
|
30
|
+
// 判决帧会在这里被正常解析并抛错,同时 chat.js 已用同一批帧定出状态码挂上门面。
|
|
31
|
+
let buf = prefetched.map((b) => dec.decode(b, { stream: true })).join("");
|
|
27
32
|
let usage = null;
|
|
28
33
|
let finish = "stop";
|
|
29
34
|
let sawContent = false;
|
|
@@ -32,40 +37,58 @@ export function reshapeQoderStream(upstreamRes, { model, chatId }) {
|
|
|
32
37
|
async start(ctrl) {
|
|
33
38
|
const reader = src.getReader();
|
|
34
39
|
const send = (t) => ctrl.enqueue(enc.encode(t));
|
|
40
|
+
let chunkIdx = 0;
|
|
41
|
+
let contentLen = 0;
|
|
42
|
+
let lineIdx = 0;
|
|
35
43
|
try {
|
|
36
|
-
|
|
44
|
+
sdbg(`[enter] model=${model} chatId=${chatId} ct=${upstreamRes.headers.get("content-type")} status=${upstreamRes.status}`);
|
|
45
|
+
// role: assistant will be attached on first real delta to avoid premature payload commit
|
|
37
46
|
for (;;) {
|
|
38
47
|
const { done, value } = await reader.read();
|
|
39
|
-
if (value)
|
|
48
|
+
if (value) {
|
|
49
|
+
const text = dec.decode(value, { stream: !done });
|
|
50
|
+
buf += text;
|
|
51
|
+
sdbg(`[chunk#${chunkIdx++}] bytes=${value.byteLength} done=${done} decodedLen=${text.length} raw=${JSON.stringify(text.slice(0, 500))}`);
|
|
52
|
+
}
|
|
40
53
|
const lines = buf.split("\n");
|
|
41
54
|
buf = lines.pop();
|
|
55
|
+
sdbg(`[buf] lines=${lines.length} leftover=${JSON.stringify(buf.slice(0, 200))}`);
|
|
42
56
|
for (const raw of lines) {
|
|
43
57
|
const line = raw.replace(/\r$/, "");
|
|
44
|
-
|
|
58
|
+
lineIdx++;
|
|
59
|
+
if (!line.startsWith("data:")) { sdbg(`[line#${lineIdx}] non-data: ${JSON.stringify(line.slice(0, 200))}`); continue; }
|
|
45
60
|
const payload = line.slice(5).trim();
|
|
46
|
-
if (!payload || payload === "[DONE]") continue;
|
|
61
|
+
if (!payload || payload === "[DONE]") { sdbg(`[line#${lineIdx}] skip: ${JSON.stringify(payload)}`); continue; }
|
|
47
62
|
const d = extractDelta(payload);
|
|
48
|
-
|
|
63
|
+
sdbg(`[line#${lineIdx}] payload=${JSON.stringify(payload.slice(0, 800))}`);
|
|
64
|
+
sdbg(`[line#${lineIdx}] parsed keys=${Object.keys(d).join(",")} content=${JSON.stringify((d.content || "").slice(0, 200))} reasoning=${JSON.stringify((d.reasoning || "").slice(0, 200))} toolCalls=${(d.toolCalls || []).length} usageIn=${d.usageIn} usageOut=${d.usageOut} err=${!!d.err}`);
|
|
65
|
+
if (d.err) { sdbg(`[line#${lineIdx}] ERROR from extractDelta: ${JSON.stringify(d.err)}`); throw d.err; }
|
|
49
66
|
if (d.usageIn || d.usageOut) {
|
|
50
67
|
usage = { prompt_tokens: d.usageIn, completion_tokens: d.usageOut, total_tokens: d.usageIn + d.usageOut };
|
|
68
|
+
sdbg(`[usage] ${JSON.stringify(usage)}`);
|
|
51
69
|
}
|
|
52
70
|
const delta = {};
|
|
53
|
-
if (
|
|
71
|
+
if (!sawContent) delta.role = "assistant";
|
|
72
|
+
if (d.content) { delta.content = d.content; contentLen += d.content.length; sawContent = true; }
|
|
54
73
|
if (d.reasoning) { delta.reasoning_content = d.reasoning; sawContent = true; }
|
|
55
74
|
if (d.toolCalls) { delta.tool_calls = d.toolCalls; finish = "tool_calls"; sawContent = true; }
|
|
56
75
|
if (Object.keys(delta).length) send(openaiChunk({ model, chatId, delta }));
|
|
76
|
+
sdbg(`[acc] line#=${lineIdx} sawContent=${sawContent} contentLen=${contentLen} finish=${finish} usage=${JSON.stringify(usage)}`);
|
|
57
77
|
}
|
|
58
78
|
if (done) break;
|
|
59
79
|
}
|
|
80
|
+
sdbg(`[end] totalChunks=${chunkIdx} totalLines=${lineIdx} sawContent=${sawContent} contentLen=${contentLen} finish=${finish} usage=${JSON.stringify(usage)}`);
|
|
60
81
|
if (!sawContent && !usage) {
|
|
82
|
+
sdbg(`[empty-stream] sawContent=false usage=null → throw 502 empty upstream stream`);
|
|
61
83
|
throw { kind: "upstream", status: 502, detail: "empty upstream stream" };
|
|
62
84
|
}
|
|
63
85
|
send(openaiChunk({ model, chatId, delta: {}, finish, usage }));
|
|
64
86
|
send("data: [DONE]\n\n");
|
|
65
87
|
finishSent = true;
|
|
66
88
|
} catch (e) {
|
|
89
|
+
sdbg(`[catch] finishSent=${finishSent} e=${JSON.stringify(e?.message || e)}`);
|
|
67
90
|
if (!finishSent) {
|
|
68
|
-
try { send(openaiChunk({ model, chatId, delta: {} })); } catch {}
|
|
91
|
+
if (sawContent) { try { send(openaiChunk({ model, chatId, delta: {} })); } catch {} }
|
|
69
92
|
try { send(errorEvent(e)); } catch {}
|
|
70
93
|
try { send("data: [DONE]\n\n"); } catch {}
|
|
71
94
|
finishSent = true;
|
|
@@ -144,7 +144,11 @@ export function createRelayPipeline({
|
|
|
144
144
|
try { _logError(actual, 502, `empty turn${_why}${_err}`); } catch {}
|
|
145
145
|
_evt("upstream-error", { reqId, model: actual, status: 502, message: "empty turn", timing: null, ...upstreamEcho(upRes) });
|
|
146
146
|
_evt("fallback", { reqId, from: actual, to: null, reason: "empty turn" });
|
|
147
|
-
|
|
147
|
+
let _errMsg = `EMPTY_MODEL_RESPONSE: upstream returned 200 with no content${_why}${_err} — retry or rephrase`;
|
|
148
|
+
if (_d.upstreamErrorText && _d.upstreamErrorText.includes("retryAfterSeconds")) {
|
|
149
|
+
_errMsg = _d.upstreamErrorText;
|
|
150
|
+
}
|
|
151
|
+
return { handled: false, upRes: null, lastErr: { model: actual, upstream: null, status: 502, message: _errMsg } };
|
|
148
152
|
}
|
|
149
153
|
|
|
150
154
|
// 5a. 首块超时未写字节 → 回退(显式 timedOut 字段,status 只是 HTTP 语义展示)
|
package/src/routes/stream.js
CHANGED
|
@@ -35,12 +35,40 @@ function hasPayload(text) {
|
|
|
35
35
|
// 否则返回 null(透传)。解析失败一律透传(默认保安全)。
|
|
36
36
|
// 背景:网关把上游失败包成 HTTP 200 SSE;暂扣后下游流式 UI 不再展示瞬时错误
|
|
37
37
|
//(如 Vertex 503 + google fallback 400),本轮走空转重试,客户端只看到最终结果。
|
|
38
|
+
// Note: 流内错误包络捕获与 retryAfterSeconds 错误直出 — 见 .agents/notes/implemented/bug-fix/2026-09-28-retry-after-surfacing-error.md
|
|
39
|
+
function extractRetryAfterSec(obj) {
|
|
40
|
+
if (!obj) return null;
|
|
41
|
+
if (typeof obj === "number") return obj;
|
|
42
|
+
if (typeof obj === "string") {
|
|
43
|
+
const m = obj.match(/"retryAfterSeconds"\s*:\s*(\d+)/i) || obj.match(/\bretryAfterSeconds\s*[:=]\s*(\d+)/i);
|
|
44
|
+
if (m) return Number(m[1]);
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(obj);
|
|
47
|
+
return extractRetryAfterSec(parsed);
|
|
48
|
+
} catch {}
|
|
49
|
+
}
|
|
50
|
+
if (typeof obj === "object") {
|
|
51
|
+
if (Number.isFinite(obj.retryAfterSeconds)) return Number(obj.retryAfterSeconds);
|
|
52
|
+
if (obj.message) {
|
|
53
|
+
const r = extractRetryAfterSec(obj.message);
|
|
54
|
+
if (r != null) return r;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
38
60
|
function holdableChunk(txt) {
|
|
39
61
|
if (typeof txt !== "string" || !txt.includes("data:")) return null;
|
|
40
62
|
let sawData = false;
|
|
63
|
+
let sawEventError = false;
|
|
64
|
+
let retrySec = null;
|
|
41
65
|
const errs = [];
|
|
42
|
-
for (const
|
|
43
|
-
const t =
|
|
66
|
+
for (const rawLine of txt.split("\n")) {
|
|
67
|
+
const t = rawLine.trim();
|
|
68
|
+
if (t.startsWith("event:") && t.slice(6).trim() === "error") {
|
|
69
|
+
sawEventError = true;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
44
72
|
if (!t.startsWith("data:")) continue;
|
|
45
73
|
const d = t.slice(5).trim();
|
|
46
74
|
if (!d || d === "[DONE]") continue;
|
|
@@ -53,15 +81,26 @@ function holdableChunk(txt) {
|
|
|
53
81
|
const msg = c0.message || {};
|
|
54
82
|
const out = delta.content || msg.content || delta.tool_calls || msg.tool_calls || delta.reasoning || msg.reasoning;
|
|
55
83
|
if (out && !(Array.isArray(out) && out.length === 0)) return null;
|
|
84
|
+
|
|
85
|
+
const r = extractRetryAfterSec(j);
|
|
86
|
+
if (r != null) {
|
|
87
|
+
retrySec = r;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
56
90
|
if (j.error && typeof j.error === "object") {
|
|
57
91
|
const m = j.error.message || j.error.code || "";
|
|
58
92
|
if (m) errs.push(String(m).slice(0, 300));
|
|
59
|
-
} else if (
|
|
60
|
-
|
|
93
|
+
} else if (sawEventError && j.message) {
|
|
94
|
+
errs.push(String(j.message).slice(0, 300));
|
|
95
|
+
} else if (c0.finish_reason === "error") {
|
|
96
|
+
errs.push("finish_reason=error 空帧");
|
|
61
97
|
}
|
|
62
98
|
}
|
|
63
99
|
if (!sawData) return null;
|
|
64
|
-
|
|
100
|
+
if (retrySec != null) return `上游供应商触发 retryAfterSeconds: ${retrySec} ,请等候重试`;
|
|
101
|
+
if (errs.length) return errs.join(" | ");
|
|
102
|
+
if (sawEventError) return "event: error 空帧";
|
|
103
|
+
return null;
|
|
65
104
|
}
|
|
66
105
|
|
|
67
106
|
// 自持 reader 优先:for-await 会锁定 ReadableStream,使 body.cancel() 必 reject(真流上等于空操作),
|