mslxdff 0.1.233 → 0.1.235
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/bin/mslxdff.js +3 -3
- package/docs/cli_help_mini.md +149 -0
- package/package.json +1 -1
- package/src/auto.js +264 -264
- package/src/bench/cline-bench.js +42 -42
- package/src/bench/probe.js +70 -70
- package/src/bench/report.js +162 -162
- package/src/bench/runner.js +77 -77
- package/src/bench/via-probe.js +124 -124
- package/src/bench/via-routes.js +87 -87
- package/src/bench/workbuddy-bench.js +54 -54
- package/src/chat/engine.js +160 -160
- package/src/chat/gateway.js +163 -163
- package/src/chat/orchestrator.js +234 -234
- package/src/chat/prompt.js +70 -70
- package/src/chat/repl.js +88 -88
- package/src/chat/terminal.js +135 -135
- package/src/chat/tools.js +306 -306
- package/src/chat-pipeline/index.js +155 -155
- package/src/chat-pipeline/policy.js +84 -84
- package/src/chat-pipeline/serial-trial.js +16 -3
- package/src/chat-pipeline/upstream-5xx-retry.js +72 -0
- package/src/cli/commands/group.js +266 -266
- package/src/cli/commands/model/picks.js +50 -50
- package/src/cli/commands/provider/bench-via.js +247 -247
- package/src/cli/commands/provider/bench.js +141 -141
- package/src/cli/commands/provider/index.js +139 -139
- package/src/cli/commands/provider/models.js +149 -149
- package/src/cli/commands/provider/qwenwork-login.js +119 -119
- package/src/cli/commands/provider/raccoon-quota.js +156 -156
- package/src/cli/commands/sync.js +310 -310
- package/src/cli/status.js +279 -279
- package/src/daemon.js +246 -246
- package/src/model-capabilities/enrich.js +86 -86
- package/src/model-capabilities/index.js +183 -183
- package/src/model-capabilities/parse.js +70 -70
- package/src/models.js +293 -293
- package/src/peers.js +345 -345
- package/src/providers/AGENTS.md +49 -0
- package/src/providers/agentrouter.js +238 -238
- package/src/providers/base.js +164 -164
- package/src/providers/cline/auth.js +228 -228
- package/src/providers/cline/chat.js +307 -307
- package/src/providers/cline.js +2 -2
- package/src/providers/codearts/chat.js +207 -207
- package/src/providers/dispatcher.js +147 -147
- package/src/providers/globalqwenwork/cosy.js +123 -123
- package/src/providers/globalqwenwork/crypto.js +218 -218
- package/src/providers/globalqwenwork/http.js +20 -20
- package/src/providers/globalqwenwork/rsa.js +54 -54
- package/src/providers/globalqwenwork/sse.js +268 -268
- package/src/providers/globalqwenwork/stream.js +130 -130
- package/src/providers/globalqwenwork/upstream.js +122 -122
- package/src/providers/keyring.js +60 -60
- package/src/providers/qoder/chat.js +292 -292
- package/src/providers/qoder/index.js +311 -311
- package/src/providers/qoder/sse.js +233 -233
- package/src/providers/qwenwork/account-store.js +133 -133
- package/src/providers/qwenwork/constants.js +67 -67
- package/src/providers/qwenwork/cosy.js +120 -120
- package/src/providers/qwenwork/crypto.js +218 -218
- package/src/providers/qwenwork/http.js +20 -20
- package/src/providers/qwenwork/index.js +337 -337
- package/src/providers/qwenwork/payload.js +144 -144
- package/src/providers/qwenwork/rsa.js +54 -54
- package/src/providers/qwenwork/sse.js +268 -268
- package/src/providers/qwenwork/stream.js +130 -130
- package/src/providers/qwenwork/upstream.js +120 -120
- package/src/providers/qwenwork.js +1 -1
- package/src/providers/raccoon/const.js +219 -219
- package/src/providers/raccoon/credits.js +285 -285
- package/src/providers/raccoon/index.js +36 -4
- package/src/providers/raccoon/sticky.js +46 -0
- package/src/providers/registry.js +89 -89
- package/src/providers/workbuddy/chat.js +298 -298
- package/src/providers/workbuddy/reshape.js +152 -152
- package/src/providers/workbuddy.js +2 -2
- package/src/reasoning.js +32 -32
- package/src/routes/AGENTS.md +37 -0
- package/src/routes/chat/broadband-handler.js +80 -80
- package/src/routes/chat/gateway.js +46 -46
- package/src/routes/chat/peer-handler.js +14 -4
- package/src/routes/chat/relay-pipeline.js +282 -282
- package/src/routes/chat/via-route-handler.js +156 -156
- package/src/routes/hedge.js +260 -260
- package/src/routes/models-route.js +167 -167
- package/src/routes/peer-serial.js +5 -2
- package/src/routes/peers.js +373 -373
- package/src/routes/stream.js +364 -364
- package/src/runtime/bootstrap.js +75 -75
- package/src/runtime/keep-warm.js +135 -135
- package/src/runtime/provider-gate.js +75 -75
- package/src/runtime/providers-setup.js +187 -187
- package/src/runtime/raccoon-checkin.js +175 -175
- package/src/server.js +85 -85
- package/src/state/schemas/allowlist.js +92 -92
- package/src/sync-opencode.js +280 -280
- package/src/transport/index.js +244 -244
- package/src/transport/pool.js +56 -56
- package/src/transport/retry.js +24 -24
- package/src/transport/sse.js +93 -93
- package/src/upstream-probe/display.js +52 -52
- package/src/upstream-probe/probe.js +49 -49
- package/src/upstream-probe/rotate.js +110 -110
- package/src/upstream-probe/start.js +45 -45
- package/src/upstream.js +355 -355
|
@@ -1,233 +1,233 @@
|
|
|
1
|
-
// qoder 上游 SSE 信封解析(转译 qoder2api bridge.go ExtractDelta)
|
|
2
|
-
// 帧形态:data:{"headers":{},"body":"<内层JSON>","statusCodeValue":200}
|
|
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
|
-
|
|
8
|
-
export function extractDelta(dataLine) {
|
|
9
|
-
let wrapper;
|
|
10
|
-
try { wrapper = JSON.parse(dataLine); } catch (e) {
|
|
11
|
-
ddbg(`[envelope-parse-fail] ${e.message} line=${dataLine.slice(0, 300)}`);
|
|
12
|
-
return {};
|
|
13
|
-
}
|
|
14
|
-
if (wrapper.statusCodeValue !== undefined && Number(wrapper.statusCodeValue) !== 200) {
|
|
15
|
-
const detail = typeof wrapper.body === "string" && wrapper.body ? wrapper.body : JSON.stringify(wrapper).slice(0, 400);
|
|
16
|
-
// 登录过期(code 105)与非 200 容器同层:先于额度/排队判,命中交门面走「刷新令牌→原地重发」,
|
|
17
|
-
// 绝不可当普通 403 冷却换号(实测会把整池过期号轮一遍,每号还叠 3 次空转重试,白烧)。
|
|
18
|
-
const login = findLoginSignal(wrapper);
|
|
19
|
-
if (login) return { err: { kind: "auth", status: 401, detail: login.slice(0, 300), loginExpired: true } };
|
|
20
|
-
// 额度耗尽可能裹在非 200 信封里,且实测是**多层嵌套**(外层 statusCodeValue:403 →
|
|
21
|
-
// code:10605 → 内层 code:"110" / "Billing daily count exceeded")。只看一层会把额度错
|
|
22
|
-
// 当普通 403;显式扫各层信号才能可靠命中(排队 10605 无 110 信号,天然不误判)。
|
|
23
|
-
const quota = findQuotaSignal(wrapper);
|
|
24
|
-
if (quota) return { err: { kind: "quota", status: 429, detail: quota.slice(0, 300) } };
|
|
25
|
-
// 排队与额度共用 10605 容器 → 判序必须「先额度、后排队」。命中排队只在既有 upstream 上挂旗标,
|
|
26
|
-
// 不新增 kind:errorStatus 与流内 error 的 type 字段一律不变(对外契约零改动),
|
|
27
|
-
// 门面据 x-mslxdff-qoder-queued 走队列档冷却 + 同请求换号。
|
|
28
|
-
const queued = findQueueSignal(wrapper);
|
|
29
|
-
if (queued) {
|
|
30
|
-
const ra = retryAfterFrom(detail);
|
|
31
|
-
return { err: { kind: "upstream", status: Number(wrapper.statusCodeValue) || 502, detail, queued: true, code: queued.code, ...(queued.noCapacity ? { noCapacity: true } : {}), ...ra } };
|
|
32
|
-
}
|
|
33
|
-
return { err: { kind: "upstream", status: Number(wrapper.statusCodeValue) || 502, detail } };
|
|
34
|
-
}
|
|
35
|
-
const inner = wrapper.body;
|
|
36
|
-
if (!inner || typeof inner !== "string") {
|
|
37
|
-
ddbg(`[body-not-string] bodyType=${typeof inner} envelopeKeys=${Object.keys(wrapper).join(",")} envelope=${dataLine.slice(0, 300)}`);
|
|
38
|
-
return {};
|
|
39
|
-
}
|
|
40
|
-
let j;
|
|
41
|
-
try { j = JSON.parse(inner); } catch (e) {
|
|
42
|
-
ddbg(`[inner-parse-fail] ${e.message} body=${inner.slice(0, 300)}`);
|
|
43
|
-
return {};
|
|
44
|
-
}
|
|
45
|
-
// 实测真身形态:statusCodeValue:200 信封,内层 body 直接 {"code":"105","message":"Login expired"}。
|
|
46
|
-
// 只认内层结构化 code(正常 chat chunk 无顶层 code 字段,正文文本永不匹配 → 零误伤)。
|
|
47
|
-
if (isLoginExpired(j.code, j.message)) return { err: { kind: "auth", status: 401, code: j.code, detail: String(j.message || "").slice(0, 300), loginExpired: true } };
|
|
48
|
-
let usageIn = 0, usageOut = 0;
|
|
49
|
-
if (j.usage && typeof j.usage === "object") {
|
|
50
|
-
usageIn = Math.trunc(Number(j.usage.prompt_tokens) || 0);
|
|
51
|
-
usageOut = Math.trunc(Number(j.usage.completion_tokens) || 0);
|
|
52
|
-
}
|
|
53
|
-
const choices = Array.isArray(j.choices) ? j.choices : [];
|
|
54
|
-
let finishReason = "";
|
|
55
|
-
for (const ch of choices) {
|
|
56
|
-
const fr = ch?.finish_reason;
|
|
57
|
-
if (typeof fr === "string" && fr) finishReason = fr;
|
|
58
|
-
const delta = ch?.delta;
|
|
59
|
-
if (!delta) continue;
|
|
60
|
-
const role = typeof delta.role === "string" ? delta.role : "";
|
|
61
|
-
const content = typeof delta.content === "string" ? delta.content : "";
|
|
62
|
-
const reasoning = typeof delta.reasoning_content === "string" ? delta.reasoning_content : "";
|
|
63
|
-
const toolCalls = Array.isArray(delta.tool_calls) && delta.tool_calls.length ? delta.tool_calls : null;
|
|
64
|
-
if (role || content || reasoning || toolCalls) {
|
|
65
|
-
return { role, content, reasoning, toolCalls, usageIn, usageOut, ...(finishReason ? { finishReason } : {}) };
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
if (typeof j.code === "string" && j.code && j.code !== "0") {
|
|
69
|
-
const msg = String(j.message || "");
|
|
70
|
-
// 判序:内容审核文案最先——上游会把 inappropriate 类文案配在 1xx/4xx 码上(既有契约锁「审核→content_policy」)。
|
|
71
|
-
if (/inappropriate|DataInspection|Sensitive|ContentFilter/i.test(msg)) return { err: { kind: "content_policy", code: j.code, detail: msg } };
|
|
72
|
-
// 额度耗尽(官方额度码族 + Billing 专属措辞):按配额错单列,门面据此换号(长冷却)。
|
|
73
|
-
if (isQuotaError(j.code, msg)) return { err: { kind: "quota", status: 429, code: j.code, detail: msg || `code=${j.code}` } };
|
|
74
|
-
// 请求被拒(内容/长度/能力,换号无用):归 request → 400 直出,不冷却不换号。
|
|
75
|
-
if (isRequestError(j.code)) return { err: { kind: "request", code: j.code, detail: msg || `code=${j.code}` } };
|
|
76
|
-
return { err: { kind: "business", code: j.code, detail: msg } };
|
|
77
|
-
}
|
|
78
|
-
if (finishReason) return { finishReason, usageIn, usageOut };
|
|
79
|
-
if (usageIn > 0 || usageOut > 0) return { usageIn, usageOut };
|
|
80
|
-
// 走到这里 = 帧既无 content/tool_calls、也无业务错、也无 usage。空轮排查的关键取证点:
|
|
81
|
-
// 把内层 JSON 的顶层键与 choices 原样打出来,一眼看出上游改了什么字段名。
|
|
82
|
-
ddbg(`[no-delta] innerKeys=${Object.keys(j).join(",")} choicesLen=${choices.length} inner=${JSON.stringify(j).slice(0, 400)}`);
|
|
83
|
-
return {};
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** 额度耗尽识别:Qoder 额度码家族(110、112–114、117–119、122)+ Billing 专属措辞。
|
|
87
|
-
* 旧版只认 110——实测上游 403 信封内层发同族码时会被误归 business→502,
|
|
88
|
-
* 门面按普通坏号短冷却 30s 反复撞(额度按天重置,短冷却等于白烧),故补全。
|
|
89
|
-
* 收紧两处(均为实测冲突证据,宁缺勿滥):
|
|
90
|
-
* - code 115("quota exceeded" 泛化措辞)归业务错(既有契约:不误伤);
|
|
91
|
-
* - code 116 官方表写 Team Credits,但现网实测被复用为 DataInspection 内容审核 → 不入额度族,留给 content_policy。 */
|
|
92
|
-
const QUOTA_CODES = new Set(["110", "112", "113", "114", "117", "118", "119", "122"]);
|
|
93
|
-
export function isQuotaError(code, message) {
|
|
94
|
-
if (QUOTA_CODES.has(String(code || "").trim())) return true;
|
|
95
|
-
return /billing\s*(daily)?\s*(count|limit|quota)?\s*exceed/i.test(String(message || ""));
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// 请求拒绝码(对齐 magpie/CLIProxyAPI 的 REQUEST_CODES 真值):这些是「这条请求被拒」而非「这个号不行」——
|
|
99
|
-
// 敏感内容(406)/范围不支持(416)/能力不支持(430)/输入过长(80411)/图太多(80412)/自定义模型服务侧(1004xx)。
|
|
100
|
-
// 换号救不了(同一条请求发给哪个号都被拒),故归 request → errorStatus 折 400 直出,不冷却不换号。
|
|
101
|
-
const REQUEST_CODES = new Set(["406", "416", "430", "80411", "80412", "100400", "100401", "100402", "100403"]);
|
|
102
|
-
export function isRequestError(code) {
|
|
103
|
-
return REQUEST_CODES.has(String(code || "").trim());
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// 显式额度信号扫描:信封各层里找 code 110 / Billing 明确措辞。
|
|
107
|
-
// 限深 4 层(实测 3 层足够,留一层余量),字符串层自动尝试 JSON.parse 后继续下钻。
|
|
108
|
-
// 排队 10605(isQueued/serviceAvailable)无 110 信号 → 返回 null,天然不误判为额度。
|
|
109
|
-
// 排队容器码(实测与额度错共用同一容器:外层 403 → code 10605 → 内层才分额度/排队)
|
|
110
|
-
const QUEUE_CODE = "10605";
|
|
111
|
-
const MAX_ENVELOPE_DEPTH = 4;
|
|
112
|
-
export function findQuotaSignal(value, depth = 0) {
|
|
113
|
-
if (value == null || depth > MAX_ENVELOPE_DEPTH) return null;
|
|
114
|
-
if (typeof value === "string") {
|
|
115
|
-
const s = value.trim();
|
|
116
|
-
if (!s) return null;
|
|
117
|
-
if (isQuotaError("", s)) return s;
|
|
118
|
-
try {
|
|
119
|
-
return findQuotaSignal(JSON.parse(s), depth + 1);
|
|
120
|
-
} catch {
|
|
121
|
-
return null;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
if (typeof value !== "object") return null;
|
|
125
|
-
if (isQuotaError(value.code, value.message)) return String(value.message || value.code || "");
|
|
126
|
-
for (const v of Object.values(value)) {
|
|
127
|
-
const hit = findQuotaSignal(v, depth + 1);
|
|
128
|
-
if (hit) return hit;
|
|
129
|
-
}
|
|
130
|
-
return null;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** 排队识别(与额度共用 10605 容器 → 判序必须先额度、后排队的纯函数):
|
|
134
|
-
* 命中条件 = 容器码 10605,或内层显式 isQueued:true / serviceAvailable:false。
|
|
135
|
-
* 实测形态:403 信封 → code 10605 → message 里带 isQueued:true、retryAfterSeconds:30。
|
|
136
|
-
* noCapacity 子型(2026-10-22 现网):isQueued:true + queueCount:0 + serviceAvailable:false + subCode:RESOURCE_LIMIT
|
|
137
|
-
* = 「p3 队列此刻无容量、无人在排」——同档位整池共享同一队列,换号救不了,只能等 Retry-After。
|
|
138
|
-
* 2026-10-22 真机回归:现网 HTTP 403 body 是四层转义信封
|
|
139
|
-
* {"error":{"message":"{\"code\":\"403\",\"message\":\"{\\\"code\\\":\\\"10605\\\",…}}"}——
|
|
140
|
-
* 引号被叠成 \"isQueued\",带引号的正则在第 0 层永远打不中,纯靠下钻又撞深度顶。
|
|
141
|
-
* 与 retryAfterFrom 同法:判决前先剥反斜杠归一,令任意嵌套层度的字面量都还原为可匹配形态。 */
|
|
142
|
-
export function isQueueSignal(code, message) {
|
|
143
|
-
if (String(code || "").trim() === QUEUE_CODE) return true;
|
|
144
|
-
const s = String(message || "").replace(/\\/g, "");
|
|
145
|
-
if (!s) return false;
|
|
146
|
-
return /"isQueued"\s*:\s*true/.test(s) || /"serviceAvailable"\s*:\s*false/.test(s);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// noCapacity 判据(isQueueSignal 已命中为前提):队列空(queueCount:0)且服务不可用(serviceAvailable:false)。
|
|
150
|
-
// 与「真排队等放行」(queueCount>0)区分:前者换号无用(整池同队列同容量),后者可能另一号有空位。
|
|
151
|
-
// 剥反斜杠同 isQueueSignal(真机四层转义,见上)。
|
|
152
|
-
export function isNoCapacitySignal(message) {
|
|
153
|
-
const s = String(message || "").replace(/\\/g, "");
|
|
154
|
-
if (!s) return false;
|
|
155
|
-
return /"queueCount"\s*:\s*0/.test(s) && /"serviceAvailable"\s*:\s*false/.test(s);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** 排队信号扫描:与 findQuotaSignal 同构(同限深、字符串层自动 parse 下钻),命中返回 { code, noCapacity }。 */
|
|
159
|
-
export function findQueueSignal(value, depth = 0) {
|
|
160
|
-
if (value == null || depth > MAX_ENVELOPE_DEPTH) return null;
|
|
161
|
-
if (typeof value === "string") {
|
|
162
|
-
const s = value.trim();
|
|
163
|
-
if (!s) return null;
|
|
164
|
-
if (isQueueSignal("", s)) return { code: QUEUE_CODE, noCapacity: isNoCapacitySignal(s) };
|
|
165
|
-
try {
|
|
166
|
-
return findQueueSignal(JSON.parse(s), depth + 1);
|
|
167
|
-
} catch {
|
|
168
|
-
return null;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
if (typeof value !== "object") return null;
|
|
172
|
-
if (isQueueSignal(value.code, value.message)) {
|
|
173
|
-
return { code: String(value.code || QUEUE_CODE), noCapacity: isNoCapacitySignal(String(value.message || "")) };
|
|
174
|
-
}
|
|
175
|
-
for (const v of Object.values(value)) {
|
|
176
|
-
const hit = findQueueSignal(v, depth + 1);
|
|
177
|
-
if (hit) return hit;
|
|
178
|
-
}
|
|
179
|
-
return null;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// 登录过期(code 105 "Login expired"):令牌过期≠号坏——官方 CLI 临期自动续,
|
|
183
|
-
// 识别命中后门面用 refresh_token 换新 token 原地重发;refresh 也被拒(真作废)才长冷却该号并提示重登。
|
|
184
|
-
export function isLoginExpired(code, message) {
|
|
185
|
-
if (String(code || "").trim() === "105") return true;
|
|
186
|
-
const s = String(message || "");
|
|
187
|
-
// 只认带 code 的结构化形态(正文里出现 "Login expired" 字样不得误伤)。
|
|
188
|
-
// (?![0-9]) 数字边界是 2026-10-22 真机教训:排队码 10605 以 105 开头,
|
|
189
|
-
// 无边界时 "code":"10605" 被当前缀命中 → login 抢在 queue 判决前误判,noCapacity 链全哑。
|
|
190
|
-
return /"code"\s*:\s*"?105"?(?![0-9])/.test(s);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/** 登录信号扫描:与 findQuotaSignal 同构(限深 4 层、字符串层自动 parse 下钻)。 */
|
|
194
|
-
export function findLoginSignal(value, depth = 0) {
|
|
195
|
-
if (value == null || depth > MAX_ENVELOPE_DEPTH) return null;
|
|
196
|
-
if (typeof value === "string") {
|
|
197
|
-
const s = value.trim();
|
|
198
|
-
if (!s) return null;
|
|
199
|
-
if (isLoginExpired("", s)) return s;
|
|
200
|
-
try {
|
|
201
|
-
return findLoginSignal(JSON.parse(s), depth + 1);
|
|
202
|
-
} catch {
|
|
203
|
-
return null;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
if (typeof value !== "object") return null;
|
|
207
|
-
if (isLoginExpired(value.code, value.message)) return String(value.message || "Login expired");
|
|
208
|
-
for (const v of Object.values(value)) {
|
|
209
|
-
const hit = findLoginSignal(v, depth + 1);
|
|
210
|
-
if (hit) return hit;
|
|
211
|
-
}
|
|
212
|
-
return null;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// 从错误 detail(可能是 JSON 或 JSON-in-string)里抽 retryAfterSeconds → { retryAfter: 秒 }(无则 {})。
|
|
216
|
-
// 上游排队判决自带 retryAfterSeconds:30,透传给上层做 Retry-After,别让上层瞎猜退避。
|
|
217
|
-
export function retryAfterFrom(text) {
|
|
218
|
-
const m = String(text || "").replace(/\\/g, "").match(/"retryAfterSeconds"\s*:\s*(\d+)/i);
|
|
219
|
-
if (!m) return {};
|
|
220
|
-
const n = Number(m[1]);
|
|
221
|
-
return Number.isFinite(n) && n > 0 ? { retryAfter: n } : {};
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// 错误分类 → HTTP 状态(对齐 errors.go ErrorStatus 语义;request=请求被拒折 400 直出)
|
|
225
|
-
export function errorStatus(err) {
|
|
226
|
-
if (!err) return 500;
|
|
227
|
-
if (err.kind === "content_policy") return 400;
|
|
228
|
-
if (err.kind === "request") return 400; // 换号无用:如实 400 给客户端,不冷却不换号
|
|
229
|
-
if (err.kind === "auth") return [401, 403].includes(err.status) ? err.status : 401;
|
|
230
|
-
if (err.kind === "upstream") return [401, 403, 429].includes(err.status) ? err.status : 502;
|
|
231
|
-
if (err.kind === "quota") return 429; // 额度错按限流语义出(门面据此换号/冷却)
|
|
232
|
-
return 502;
|
|
233
|
-
}
|
|
1
|
+
// qoder 上游 SSE 信封解析(转译 qoder2api bridge.go ExtractDelta)
|
|
2
|
+
// 帧形态:data:{"headers":{},"body":"<内层JSON>","statusCodeValue":200}
|
|
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
|
+
|
|
8
|
+
export function extractDelta(dataLine) {
|
|
9
|
+
let wrapper;
|
|
10
|
+
try { wrapper = JSON.parse(dataLine); } catch (e) {
|
|
11
|
+
ddbg(`[envelope-parse-fail] ${e.message} line=${dataLine.slice(0, 300)}`);
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
if (wrapper.statusCodeValue !== undefined && Number(wrapper.statusCodeValue) !== 200) {
|
|
15
|
+
const detail = typeof wrapper.body === "string" && wrapper.body ? wrapper.body : JSON.stringify(wrapper).slice(0, 400);
|
|
16
|
+
// 登录过期(code 105)与非 200 容器同层:先于额度/排队判,命中交门面走「刷新令牌→原地重发」,
|
|
17
|
+
// 绝不可当普通 403 冷却换号(实测会把整池过期号轮一遍,每号还叠 3 次空转重试,白烧)。
|
|
18
|
+
const login = findLoginSignal(wrapper);
|
|
19
|
+
if (login) return { err: { kind: "auth", status: 401, detail: login.slice(0, 300), loginExpired: true } };
|
|
20
|
+
// 额度耗尽可能裹在非 200 信封里,且实测是**多层嵌套**(外层 statusCodeValue:403 →
|
|
21
|
+
// code:10605 → 内层 code:"110" / "Billing daily count exceeded")。只看一层会把额度错
|
|
22
|
+
// 当普通 403;显式扫各层信号才能可靠命中(排队 10605 无 110 信号,天然不误判)。
|
|
23
|
+
const quota = findQuotaSignal(wrapper);
|
|
24
|
+
if (quota) return { err: { kind: "quota", status: 429, detail: quota.slice(0, 300) } };
|
|
25
|
+
// 排队与额度共用 10605 容器 → 判序必须「先额度、后排队」。命中排队只在既有 upstream 上挂旗标,
|
|
26
|
+
// 不新增 kind:errorStatus 与流内 error 的 type 字段一律不变(对外契约零改动),
|
|
27
|
+
// 门面据 x-mslxdff-qoder-queued 走队列档冷却 + 同请求换号。
|
|
28
|
+
const queued = findQueueSignal(wrapper);
|
|
29
|
+
if (queued) {
|
|
30
|
+
const ra = retryAfterFrom(detail);
|
|
31
|
+
return { err: { kind: "upstream", status: Number(wrapper.statusCodeValue) || 502, detail, queued: true, code: queued.code, ...(queued.noCapacity ? { noCapacity: true } : {}), ...ra } };
|
|
32
|
+
}
|
|
33
|
+
return { err: { kind: "upstream", status: Number(wrapper.statusCodeValue) || 502, detail } };
|
|
34
|
+
}
|
|
35
|
+
const inner = wrapper.body;
|
|
36
|
+
if (!inner || typeof inner !== "string") {
|
|
37
|
+
ddbg(`[body-not-string] bodyType=${typeof inner} envelopeKeys=${Object.keys(wrapper).join(",")} envelope=${dataLine.slice(0, 300)}`);
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
let j;
|
|
41
|
+
try { j = JSON.parse(inner); } catch (e) {
|
|
42
|
+
ddbg(`[inner-parse-fail] ${e.message} body=${inner.slice(0, 300)}`);
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
// 实测真身形态:statusCodeValue:200 信封,内层 body 直接 {"code":"105","message":"Login expired"}。
|
|
46
|
+
// 只认内层结构化 code(正常 chat chunk 无顶层 code 字段,正文文本永不匹配 → 零误伤)。
|
|
47
|
+
if (isLoginExpired(j.code, j.message)) return { err: { kind: "auth", status: 401, code: j.code, detail: String(j.message || "").slice(0, 300), loginExpired: true } };
|
|
48
|
+
let usageIn = 0, usageOut = 0;
|
|
49
|
+
if (j.usage && typeof j.usage === "object") {
|
|
50
|
+
usageIn = Math.trunc(Number(j.usage.prompt_tokens) || 0);
|
|
51
|
+
usageOut = Math.trunc(Number(j.usage.completion_tokens) || 0);
|
|
52
|
+
}
|
|
53
|
+
const choices = Array.isArray(j.choices) ? j.choices : [];
|
|
54
|
+
let finishReason = "";
|
|
55
|
+
for (const ch of choices) {
|
|
56
|
+
const fr = ch?.finish_reason;
|
|
57
|
+
if (typeof fr === "string" && fr) finishReason = fr;
|
|
58
|
+
const delta = ch?.delta;
|
|
59
|
+
if (!delta) continue;
|
|
60
|
+
const role = typeof delta.role === "string" ? delta.role : "";
|
|
61
|
+
const content = typeof delta.content === "string" ? delta.content : "";
|
|
62
|
+
const reasoning = typeof delta.reasoning_content === "string" ? delta.reasoning_content : "";
|
|
63
|
+
const toolCalls = Array.isArray(delta.tool_calls) && delta.tool_calls.length ? delta.tool_calls : null;
|
|
64
|
+
if (role || content || reasoning || toolCalls) {
|
|
65
|
+
return { role, content, reasoning, toolCalls, usageIn, usageOut, ...(finishReason ? { finishReason } : {}) };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (typeof j.code === "string" && j.code && j.code !== "0") {
|
|
69
|
+
const msg = String(j.message || "");
|
|
70
|
+
// 判序:内容审核文案最先——上游会把 inappropriate 类文案配在 1xx/4xx 码上(既有契约锁「审核→content_policy」)。
|
|
71
|
+
if (/inappropriate|DataInspection|Sensitive|ContentFilter/i.test(msg)) return { err: { kind: "content_policy", code: j.code, detail: msg } };
|
|
72
|
+
// 额度耗尽(官方额度码族 + Billing 专属措辞):按配额错单列,门面据此换号(长冷却)。
|
|
73
|
+
if (isQuotaError(j.code, msg)) return { err: { kind: "quota", status: 429, code: j.code, detail: msg || `code=${j.code}` } };
|
|
74
|
+
// 请求被拒(内容/长度/能力,换号无用):归 request → 400 直出,不冷却不换号。
|
|
75
|
+
if (isRequestError(j.code)) return { err: { kind: "request", code: j.code, detail: msg || `code=${j.code}` } };
|
|
76
|
+
return { err: { kind: "business", code: j.code, detail: msg } };
|
|
77
|
+
}
|
|
78
|
+
if (finishReason) return { finishReason, usageIn, usageOut };
|
|
79
|
+
if (usageIn > 0 || usageOut > 0) return { usageIn, usageOut };
|
|
80
|
+
// 走到这里 = 帧既无 content/tool_calls、也无业务错、也无 usage。空轮排查的关键取证点:
|
|
81
|
+
// 把内层 JSON 的顶层键与 choices 原样打出来,一眼看出上游改了什么字段名。
|
|
82
|
+
ddbg(`[no-delta] innerKeys=${Object.keys(j).join(",")} choicesLen=${choices.length} inner=${JSON.stringify(j).slice(0, 400)}`);
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 额度耗尽识别:Qoder 额度码家族(110、112–114、117–119、122)+ Billing 专属措辞。
|
|
87
|
+
* 旧版只认 110——实测上游 403 信封内层发同族码时会被误归 business→502,
|
|
88
|
+
* 门面按普通坏号短冷却 30s 反复撞(额度按天重置,短冷却等于白烧),故补全。
|
|
89
|
+
* 收紧两处(均为实测冲突证据,宁缺勿滥):
|
|
90
|
+
* - code 115("quota exceeded" 泛化措辞)归业务错(既有契约:不误伤);
|
|
91
|
+
* - code 116 官方表写 Team Credits,但现网实测被复用为 DataInspection 内容审核 → 不入额度族,留给 content_policy。 */
|
|
92
|
+
const QUOTA_CODES = new Set(["110", "112", "113", "114", "117", "118", "119", "122"]);
|
|
93
|
+
export function isQuotaError(code, message) {
|
|
94
|
+
if (QUOTA_CODES.has(String(code || "").trim())) return true;
|
|
95
|
+
return /billing\s*(daily)?\s*(count|limit|quota)?\s*exceed/i.test(String(message || ""));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 请求拒绝码(对齐 magpie/CLIProxyAPI 的 REQUEST_CODES 真值):这些是「这条请求被拒」而非「这个号不行」——
|
|
99
|
+
// 敏感内容(406)/范围不支持(416)/能力不支持(430)/输入过长(80411)/图太多(80412)/自定义模型服务侧(1004xx)。
|
|
100
|
+
// 换号救不了(同一条请求发给哪个号都被拒),故归 request → errorStatus 折 400 直出,不冷却不换号。
|
|
101
|
+
const REQUEST_CODES = new Set(["406", "416", "430", "80411", "80412", "100400", "100401", "100402", "100403"]);
|
|
102
|
+
export function isRequestError(code) {
|
|
103
|
+
return REQUEST_CODES.has(String(code || "").trim());
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 显式额度信号扫描:信封各层里找 code 110 / Billing 明确措辞。
|
|
107
|
+
// 限深 4 层(实测 3 层足够,留一层余量),字符串层自动尝试 JSON.parse 后继续下钻。
|
|
108
|
+
// 排队 10605(isQueued/serviceAvailable)无 110 信号 → 返回 null,天然不误判为额度。
|
|
109
|
+
// 排队容器码(实测与额度错共用同一容器:外层 403 → code 10605 → 内层才分额度/排队)
|
|
110
|
+
const QUEUE_CODE = "10605";
|
|
111
|
+
const MAX_ENVELOPE_DEPTH = 4;
|
|
112
|
+
export function findQuotaSignal(value, depth = 0) {
|
|
113
|
+
if (value == null || depth > MAX_ENVELOPE_DEPTH) return null;
|
|
114
|
+
if (typeof value === "string") {
|
|
115
|
+
const s = value.trim();
|
|
116
|
+
if (!s) return null;
|
|
117
|
+
if (isQuotaError("", s)) return s;
|
|
118
|
+
try {
|
|
119
|
+
return findQuotaSignal(JSON.parse(s), depth + 1);
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (typeof value !== "object") return null;
|
|
125
|
+
if (isQuotaError(value.code, value.message)) return String(value.message || value.code || "");
|
|
126
|
+
for (const v of Object.values(value)) {
|
|
127
|
+
const hit = findQuotaSignal(v, depth + 1);
|
|
128
|
+
if (hit) return hit;
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 排队识别(与额度共用 10605 容器 → 判序必须先额度、后排队的纯函数):
|
|
134
|
+
* 命中条件 = 容器码 10605,或内层显式 isQueued:true / serviceAvailable:false。
|
|
135
|
+
* 实测形态:403 信封 → code 10605 → message 里带 isQueued:true、retryAfterSeconds:30。
|
|
136
|
+
* noCapacity 子型(2026-10-22 现网):isQueued:true + queueCount:0 + serviceAvailable:false + subCode:RESOURCE_LIMIT
|
|
137
|
+
* = 「p3 队列此刻无容量、无人在排」——同档位整池共享同一队列,换号救不了,只能等 Retry-After。
|
|
138
|
+
* 2026-10-22 真机回归:现网 HTTP 403 body 是四层转义信封
|
|
139
|
+
* {"error":{"message":"{\"code\":\"403\",\"message\":\"{\\\"code\\\":\\\"10605\\\",…}}"}——
|
|
140
|
+
* 引号被叠成 \"isQueued\",带引号的正则在第 0 层永远打不中,纯靠下钻又撞深度顶。
|
|
141
|
+
* 与 retryAfterFrom 同法:判决前先剥反斜杠归一,令任意嵌套层度的字面量都还原为可匹配形态。 */
|
|
142
|
+
export function isQueueSignal(code, message) {
|
|
143
|
+
if (String(code || "").trim() === QUEUE_CODE) return true;
|
|
144
|
+
const s = String(message || "").replace(/\\/g, "");
|
|
145
|
+
if (!s) return false;
|
|
146
|
+
return /"isQueued"\s*:\s*true/.test(s) || /"serviceAvailable"\s*:\s*false/.test(s);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// noCapacity 判据(isQueueSignal 已命中为前提):队列空(queueCount:0)且服务不可用(serviceAvailable:false)。
|
|
150
|
+
// 与「真排队等放行」(queueCount>0)区分:前者换号无用(整池同队列同容量),后者可能另一号有空位。
|
|
151
|
+
// 剥反斜杠同 isQueueSignal(真机四层转义,见上)。
|
|
152
|
+
export function isNoCapacitySignal(message) {
|
|
153
|
+
const s = String(message || "").replace(/\\/g, "");
|
|
154
|
+
if (!s) return false;
|
|
155
|
+
return /"queueCount"\s*:\s*0/.test(s) && /"serviceAvailable"\s*:\s*false/.test(s);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 排队信号扫描:与 findQuotaSignal 同构(同限深、字符串层自动 parse 下钻),命中返回 { code, noCapacity }。 */
|
|
159
|
+
export function findQueueSignal(value, depth = 0) {
|
|
160
|
+
if (value == null || depth > MAX_ENVELOPE_DEPTH) return null;
|
|
161
|
+
if (typeof value === "string") {
|
|
162
|
+
const s = value.trim();
|
|
163
|
+
if (!s) return null;
|
|
164
|
+
if (isQueueSignal("", s)) return { code: QUEUE_CODE, noCapacity: isNoCapacitySignal(s) };
|
|
165
|
+
try {
|
|
166
|
+
return findQueueSignal(JSON.parse(s), depth + 1);
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (typeof value !== "object") return null;
|
|
172
|
+
if (isQueueSignal(value.code, value.message)) {
|
|
173
|
+
return { code: String(value.code || QUEUE_CODE), noCapacity: isNoCapacitySignal(String(value.message || "")) };
|
|
174
|
+
}
|
|
175
|
+
for (const v of Object.values(value)) {
|
|
176
|
+
const hit = findQueueSignal(v, depth + 1);
|
|
177
|
+
if (hit) return hit;
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 登录过期(code 105 "Login expired"):令牌过期≠号坏——官方 CLI 临期自动续,
|
|
183
|
+
// 识别命中后门面用 refresh_token 换新 token 原地重发;refresh 也被拒(真作废)才长冷却该号并提示重登。
|
|
184
|
+
export function isLoginExpired(code, message) {
|
|
185
|
+
if (String(code || "").trim() === "105") return true;
|
|
186
|
+
const s = String(message || "");
|
|
187
|
+
// 只认带 code 的结构化形态(正文里出现 "Login expired" 字样不得误伤)。
|
|
188
|
+
// (?![0-9]) 数字边界是 2026-10-22 真机教训:排队码 10605 以 105 开头,
|
|
189
|
+
// 无边界时 "code":"10605" 被当前缀命中 → login 抢在 queue 判决前误判,noCapacity 链全哑。
|
|
190
|
+
return /"code"\s*:\s*"?105"?(?![0-9])/.test(s);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 登录信号扫描:与 findQuotaSignal 同构(限深 4 层、字符串层自动 parse 下钻)。 */
|
|
194
|
+
export function findLoginSignal(value, depth = 0) {
|
|
195
|
+
if (value == null || depth > MAX_ENVELOPE_DEPTH) return null;
|
|
196
|
+
if (typeof value === "string") {
|
|
197
|
+
const s = value.trim();
|
|
198
|
+
if (!s) return null;
|
|
199
|
+
if (isLoginExpired("", s)) return s;
|
|
200
|
+
try {
|
|
201
|
+
return findLoginSignal(JSON.parse(s), depth + 1);
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (typeof value !== "object") return null;
|
|
207
|
+
if (isLoginExpired(value.code, value.message)) return String(value.message || "Login expired");
|
|
208
|
+
for (const v of Object.values(value)) {
|
|
209
|
+
const hit = findLoginSignal(v, depth + 1);
|
|
210
|
+
if (hit) return hit;
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 从错误 detail(可能是 JSON 或 JSON-in-string)里抽 retryAfterSeconds → { retryAfter: 秒 }(无则 {})。
|
|
216
|
+
// 上游排队判决自带 retryAfterSeconds:30,透传给上层做 Retry-After,别让上层瞎猜退避。
|
|
217
|
+
export function retryAfterFrom(text) {
|
|
218
|
+
const m = String(text || "").replace(/\\/g, "").match(/"retryAfterSeconds"\s*:\s*(\d+)/i);
|
|
219
|
+
if (!m) return {};
|
|
220
|
+
const n = Number(m[1]);
|
|
221
|
+
return Number.isFinite(n) && n > 0 ? { retryAfter: n } : {};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// 错误分类 → HTTP 状态(对齐 errors.go ErrorStatus 语义;request=请求被拒折 400 直出)
|
|
225
|
+
export function errorStatus(err) {
|
|
226
|
+
if (!err) return 500;
|
|
227
|
+
if (err.kind === "content_policy") return 400;
|
|
228
|
+
if (err.kind === "request") return 400; // 换号无用:如实 400 给客户端,不冷却不换号
|
|
229
|
+
if (err.kind === "auth") return [401, 403].includes(err.status) ? err.status : 401;
|
|
230
|
+
if (err.kind === "upstream") return [401, 403, 429].includes(err.status) ? err.status : 502;
|
|
231
|
+
if (err.kind === "quota") return 429; // 额度错按限流语义出(门面据此换号/冷却)
|
|
232
|
+
return 502;
|
|
233
|
+
}
|