mslxdff 0.1.231 → 0.1.232
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/package.json +1 -1
- package/src/auto.js +254 -254
- 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 +145 -145
- package/src/chat-pipeline/policy.js +84 -84
- 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 +225 -225
- package/src/peers.js +345 -345
- 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 +203 -203
- package/src/providers/qoder/index.js +249 -249
- package/src/providers/qoder/sse.js +145 -145
- 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 +202 -202
- package/src/providers/raccoon/credits.js +285 -285
- package/src/providers/registry.js +89 -89
- package/src/providers/workbuddy/auth.js +16 -0
- package/src/providers/workbuddy/chat.js +298 -264
- package/src/providers/workbuddy/client-headers.js +148 -0
- package/src/providers/workbuddy/desktop-info.js +17 -1
- package/src/providers/workbuddy/models.js +8 -20
- package/src/providers/workbuddy/reshape.js +152 -152
- package/src/providers/workbuddy.js +2 -2
- package/src/reasoning.js +32 -32
- package/src/routes/chat/broadband-handler.js +80 -80
- package/src/routes/chat/gateway.js +46 -46
- 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/peers.js +373 -373
- package/src/routes/stream.js +347 -347
- 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
- package/docs/cli_help_mini.md +0 -148
- package/src/providers/AGENTS.md +0 -49
- package/src/routes/AGENTS.md +0 -37
|
@@ -1,203 +1,203 @@
|
|
|
1
|
-
// qoder 对话服务:上游恒 stream:true;请求方 stream=true → 真流式转发边收边吐,
|
|
2
|
-
// false → 聚合回 JSON。装配走 request.js,帧格式走 sse.js,两条管线共享。
|
|
3
|
-
import { compatFetch, timeoutSignal } from "../../compat.js";
|
|
4
|
-
import { mapModel } from "./payload.js";
|
|
5
|
-
import { effortFromBody, isReasoningEnabled } from "../../thinking-effort.js";
|
|
6
|
-
import { normalizeRegion } from "./constants.js";
|
|
7
|
-
import { buildUpstreamRequest } from "./request.js";
|
|
8
|
-
import { reshapeQoderStream } from "./stream.js";
|
|
9
|
-
import { aggregateQoderStream, toCompletionJson } from "./aggregate.js";
|
|
10
|
-
import { errorStatus, extractDelta } from "./sse.js";
|
|
11
|
-
|
|
12
|
-
function hostOf(url) {
|
|
13
|
-
try { return new URL(String(url)).host; } catch { return "-"; }
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function errRes(status, msg, type = "upstream_error") {
|
|
17
|
-
return new Response(JSON.stringify({ error: { message: msg, type } }), { status, headers: { "Content-Type": "application/json" } });
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function mapUpstreamError(status, detail) {
|
|
21
|
-
return { kind: status === 401 || status === 403 ? "auth" : "upstream", status, detail: String(detail || "").slice(0, 300) };
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// 预读上限:判决恒在首帧(实测 457 字节),8 帧足够覆盖"角色帧先到、判决帧随后",
|
|
25
|
-
// 又不会把首字节延迟拖到不可控。超窗未得判决即放弃(退化为无冷却的流内 error,见 SPEC 已知上限)。
|
|
26
|
-
const PEEK_MAX_FRAMES = 8;
|
|
27
|
-
|
|
28
|
-
// 预读上游首帧只为取「流内判决」:qoder 把限流/鉴权判决裹在 HTTP 200 的 SSE 信封里
|
|
29
|
-
//(statusCodeValue:403 + serviceAvailable:false),upRes.status 恒 200,判决只有读完帧才知道。
|
|
30
|
-
// 不预读则状态码永远出不来流式路径 → 门面不冷却该号(ADR-0036 的粘号会把重试继续粘在它上面)。
|
|
31
|
-
// 返回 { res, status, frames }:res 是把预读帧回灌后的等价上游响应,frames 交回灌用。
|
|
32
|
-
// 任何异常都降级为「无判决」(status=0):排障手段不得变成故障源。
|
|
33
|
-
async function peekEnvelopeVerdict(upRes, cdbg) {
|
|
34
|
-
if (!upRes?.body) return { res: upRes, status: 0, quota: false, queued: false, frames: [] };
|
|
35
|
-
let reader;
|
|
36
|
-
try { reader = upRes.body.getReader(); } catch { return { res: upRes, status: 0, quota: false, queued: false, frames: [] }; }
|
|
37
|
-
const dec = new TextDecoder();
|
|
38
|
-
const frames = [];
|
|
39
|
-
let text = "";
|
|
40
|
-
let status = 0;
|
|
41
|
-
let quota = false;
|
|
42
|
-
let queued = false;
|
|
43
|
-
let decided = false;
|
|
44
|
-
try {
|
|
45
|
-
for (let i = 0; i < PEEK_MAX_FRAMES && !decided; i++) {
|
|
46
|
-
const { done, value } = await reader.read();
|
|
47
|
-
if (done) break;
|
|
48
|
-
if (value) { frames.push(value); text += dec.decode(value, { stream: true }); }
|
|
49
|
-
// 逐行判定:先到判决(err)或先到有效载荷即停,避免无谓等待
|
|
50
|
-
for (const raw of text.split("\n")) {
|
|
51
|
-
const line = raw.trim();
|
|
52
|
-
if (!line.startsWith("data:")) continue;
|
|
53
|
-
const p = line.slice(5).trim();
|
|
54
|
-
if (!p || p === "[DONE]") continue;
|
|
55
|
-
const d = extractDelta(p);
|
|
56
|
-
if (d.err) { status = Number(d.err.status) || 0; quota = d.err.kind === "quota"; queued = d.err.queued === true; decided = true; break; }
|
|
57
|
-
if (d.content || d.reasoning || d.toolCalls || d.usageIn || d.usageOut) { decided = true; break; }
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
} catch (e) {
|
|
61
|
-
cdbg(`[peek-throw] ${String(e?.message || e).slice(0, 160)}`);
|
|
62
|
-
}
|
|
63
|
-
cdbg(`[peek] frames=${frames.length} verdict=${status || "none(正常流)"} quota=${quota} queued=${queued}`);
|
|
64
|
-
// 回灌:已读帧先按原样吐出,再接续同一个 reader 的剩余部分
|
|
65
|
-
const body = new ReadableStream({
|
|
66
|
-
start(ctrl) {
|
|
67
|
-
for (const f of frames) { try { ctrl.enqueue(f); } catch {} }
|
|
68
|
-
(async () => {
|
|
69
|
-
try {
|
|
70
|
-
for (;;) {
|
|
71
|
-
const { done, value } = await reader.read();
|
|
72
|
-
if (done) break;
|
|
73
|
-
if (value) ctrl.enqueue(value);
|
|
74
|
-
}
|
|
75
|
-
} catch {}
|
|
76
|
-
try { ctrl.close(); } catch {}
|
|
77
|
-
})();
|
|
78
|
-
},
|
|
79
|
-
});
|
|
80
|
-
return { res: new Response(body, { status: upRes.status, headers: upRes.headers }), status, quota, queued, frames };
|
|
81
|
-
}
|
|
82
|
-
export function createChatService({ id = "qoder", fetchImpl, timeoutMs, getModelMeta } = {}) {
|
|
83
|
-
if (!fetchImpl) fetchImpl = compatFetch;
|
|
84
|
-
|
|
85
|
-
// body: OpenAI chat 请求;sess: 已建 COSY 会话;region: 该账号所属区(每号独立选端点)。返回 Response。
|
|
86
|
-
// 回显(可观测,不落凭据):x-mslxdff-upstream=本次请求的 host、x-mslxdff-qoder-region=cn|global;
|
|
87
|
-
// 管线层据此把"这次谁上的、打哪个站"投进模型日志(此前 provider 内部选号/切 URL 完全静默,排障无据)。
|
|
88
|
-
const withEcho = (res, url, region, pick) => {
|
|
89
|
-
try {
|
|
90
|
-
res.headers.set("x-mslxdff-upstream", hostOf(url));
|
|
91
|
-
res.headers.set("x-mslxdff-qoder-region", normalizeRegion(region));
|
|
92
|
-
// 账号选择决定(new/sticky/switch/forced):管线把它写进模型日志,"为什么会切号"才有据可查
|
|
93
|
-
if (pick) res.headers.set("x-mslxdff-qoder-account", String(pick));
|
|
94
|
-
} catch {}
|
|
95
|
-
return res;
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
// 上游状态回显(内部交接用,不落日志):流式路径按对外契约把非 200 整形成 "200 + 流内 error",
|
|
99
|
-
// 真实状态码只能靠这个头带出——否则 401/403/429/5xx 到不了 provider 门面,坏号不被冷却,
|
|
100
|
-
// 而"同请求粘号"(ADR-0036)会把后续重试继续粘在这个坏号上。
|
|
101
|
-
// Note: 流内判决需预读首帧才能拿到(HTTP 200 里裹 statusCodeValue)— 见 .agents/notes/implemented/bug-fix/2026-09-27-qoder-envelope-verdict-cooldown.md
|
|
102
|
-
const withUpstreamStatus = (res, st) => {
|
|
103
|
-
try { res.headers.set("x-mslxdff-qoder-upstream-status", String(st)); } catch {}
|
|
104
|
-
return res;
|
|
105
|
-
};
|
|
106
|
-
// 额度耗尽标记(内部交接,不落日志):门面据此换号(长冷却该号)而非普通短冷却
|
|
107
|
-
const withQuota = (res) => {
|
|
108
|
-
try { res.headers.set("x-mslxdff-qoder-quota", "1"); } catch {}
|
|
109
|
-
return res;
|
|
110
|
-
};
|
|
111
|
-
// 排队标记(内部交接,不落日志):与额度同为「换号」信号但冷却档不同——
|
|
112
|
-
// 排队是「稍后再来」,长冷却到点自动回池;额度是「今天没了」,按天重置才回。
|
|
113
|
-
const withQueued = (res) => {
|
|
114
|
-
try { res.headers.set("x-mslxdff-qoder-queued", "1"); } catch {}
|
|
115
|
-
return res;
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
async function runChat(body, sess, region = "global", pick = "") {
|
|
119
|
-
const stream = body?.stream !== false;
|
|
120
|
-
const model = body?.model || "qfmodel";
|
|
121
|
-
const chatId = "chatcmpl-qoder-" + Math.random().toString(16).slice(2, 10);
|
|
122
|
-
const tools = Array.isArray(body?.tools) && body.tools.length ? body.tools : null;
|
|
123
|
-
const messages = Array.isArray(body?.messages) ? body.messages : [];
|
|
124
|
-
const maxTokens = Number(body?.max_tokens) || 0;
|
|
125
|
-
// is_reasoning 由模型目录供数(index 注入 getModelMeta,只读 peekModels 快照缓存,chat 路径恒 0 上游调用);
|
|
126
|
-
// 冷缓存/缺失一律降级 false(= 旧行为,不阻断对话)—— P0:推理标志此前从未出门。
|
|
127
|
-
let isReasoning = false;
|
|
128
|
-
try {
|
|
129
|
-
if (getModelMeta) isReasoning = (await getModelMeta(sess, normalizeRegion(region), mapModel(model)))?.is_reasoning === true;
|
|
130
|
-
} catch {}
|
|
131
|
-
// 客户端显式档位优先于目录推断(grill-decisions Q3):关断→false、其余非空→true;无档位→沿用目录结论。
|
|
132
|
-
isReasoning = isReasoningEnabled(effortFromBody(body), isReasoning);
|
|
133
|
-
|
|
134
|
-
let upRes; let reqUrl = "";
|
|
135
|
-
const echo = (res) => withEcho(res, reqUrl, region, pick);
|
|
136
|
-
// 排障日志(QODER_DEBUG_STREAM=1 开):记录出口/状态/耗时/响应头,便于对比"200 但空轮"
|
|
137
|
-
const cdbg = (...a) => { if (process.env.QODER_DEBUG_STREAM === "1") console.log("[qoder-chat]", ...a); };
|
|
138
|
-
let upMs = 0;
|
|
139
|
-
try {
|
|
140
|
-
const req = buildUpstreamRequest({ sess, region: normalizeRegion(region), model, messages, tools, maxTokens, isReasoning });
|
|
141
|
-
reqUrl = req.url;
|
|
142
|
-
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}`);
|
|
143
|
-
const t0 = Date.now();
|
|
144
|
-
upRes = await fetchImpl(req.url, {
|
|
145
|
-
method: "POST",
|
|
146
|
-
headers: req.headers,
|
|
147
|
-
body: req.bodyStr,
|
|
148
|
-
signal: timeoutSignal(timeoutMs || 120_000),
|
|
149
|
-
});
|
|
150
|
-
upMs = Date.now() - t0;
|
|
151
|
-
cdbg(`[res] status=${upRes.status} ms=${upMs} ct=${upRes.headers.get("content-type")} host=${hostOf(reqUrl)}`);
|
|
152
|
-
} catch (e) {
|
|
153
|
-
const detail = String(e?.message || e).slice(0, 300);
|
|
154
|
-
cdbg(`[res-throw] ms=${upMs} err=${detail}`);
|
|
155
|
-
// 网络异常(超时/连接失败)折成 502:门面据此冷却该号(否则坏号恒在轮换池里)
|
|
156
|
-
if (stream) return withUpstreamStatus(echo(reshapeQoderStream(new Response("data: [DONE]\n\n"), { model, chatId })), 502);
|
|
157
|
-
return withUpstreamStatus(echo(errRes(502, detail)), 502);
|
|
158
|
-
}
|
|
159
|
-
if (upRes.status !== 200) {
|
|
160
|
-
const detail = await upRes.text().catch(() => "");
|
|
161
|
-
cdbg(`[res-non200] status=${upRes.status} body=${detail.slice(0, 300)}`);
|
|
162
|
-
const e = mapUpstreamError(upRes.status, detail);
|
|
163
|
-
if (stream) {
|
|
164
|
-
// 首包错误也走流内 error 事件(与旧 reshapeStream catch 语义一致:200 + event:error + DONE)
|
|
165
|
-
const enc = new TextEncoder();
|
|
166
|
-
const s = new ReadableStream({
|
|
167
|
-
start(c) {
|
|
168
|
-
c.enqueue(enc.encode(`data: ${JSON.stringify({ id: chatId, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), model, choices: [{ index: 0, delta: {}, finish_reason: null }] })}\n\n`));
|
|
169
|
-
c.enqueue(enc.encode(`event: error\ndata: ${JSON.stringify({ message: e.detail, type: e.kind })}\n\ndata: [DONE]\n\n`));
|
|
170
|
-
c.close();
|
|
171
|
-
},
|
|
172
|
-
});
|
|
173
|
-
// 对外仍是 200(契约不变),真实状态码经回显头交门面决定是否冷却
|
|
174
|
-
return withUpstreamStatus(echo(new Response(s, { status: 200, headers: { "Content-Type": "text/event-stream" } })), upRes.status);
|
|
175
|
-
}
|
|
176
|
-
return withUpstreamStatus(echo(errRes(errorStatus(e), e.detail, e.kind)), upRes.status);
|
|
177
|
-
}
|
|
178
|
-
if (stream) {
|
|
179
|
-
// 预读首帧取流内判决(qoder 把 403/429 裹在 200 的信封里),预读帧原样回灌给整形器。
|
|
180
|
-
// 判决状态码经 withUpstreamStatus 出门面 → index.js 据此 ring.onError 冷却该号 →
|
|
181
|
-
// 粘号选择器下次因 isCooling 为真而换号。对外仍恒 200(契约不变)。
|
|
182
|
-
const peek = await peekEnvelopeVerdict(upRes, cdbg);
|
|
183
|
-
let shaped = echo(reshapeQoderStream(peek.res, { model, chatId, prefetched: peek.frames }));
|
|
184
|
-
// 只在真有判决时挂状态码:正常流不写这个头(index.js 靠它区分"要不要冷却")
|
|
185
|
-
if (peek.status) shaped = withUpstreamStatus(shaped, peek.status);
|
|
186
|
-
if (peek.quota) shaped = withQuota(shaped);
|
|
187
|
-
// 排队旗标(内部交接):门面据此走队列档冷却并在同一请求内换号,别把排队号送回客户端
|
|
188
|
-
if (peek.queued) shaped = withQueued(shaped);
|
|
189
|
-
return shaped;
|
|
190
|
-
}
|
|
191
|
-
try {
|
|
192
|
-
const agg = await aggregateQoderStream(upRes);
|
|
193
|
-
const out = toCompletionJson({ model, chatId, ...agg });
|
|
194
|
-
return echo(new Response(JSON.stringify(out), { status: 200, headers: { "Content-Type": "application/json" } }));
|
|
195
|
-
} catch (e) {
|
|
196
|
-
const out = echo(errRes(errorStatus(e), String(e?.detail || e?.message || e).slice(0, 300), e?.kind || "upstream_error"));
|
|
197
|
-
const marked = e?.kind === "quota" ? withQuota(out) : out;
|
|
198
|
-
return e?.queued ? withQueued(marked) : marked;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
return { runChat };
|
|
203
|
-
}
|
|
1
|
+
// qoder 对话服务:上游恒 stream:true;请求方 stream=true → 真流式转发边收边吐,
|
|
2
|
+
// false → 聚合回 JSON。装配走 request.js,帧格式走 sse.js,两条管线共享。
|
|
3
|
+
import { compatFetch, timeoutSignal } from "../../compat.js";
|
|
4
|
+
import { mapModel } from "./payload.js";
|
|
5
|
+
import { effortFromBody, isReasoningEnabled } from "../../thinking-effort.js";
|
|
6
|
+
import { normalizeRegion } from "./constants.js";
|
|
7
|
+
import { buildUpstreamRequest } from "./request.js";
|
|
8
|
+
import { reshapeQoderStream } from "./stream.js";
|
|
9
|
+
import { aggregateQoderStream, toCompletionJson } from "./aggregate.js";
|
|
10
|
+
import { errorStatus, extractDelta } from "./sse.js";
|
|
11
|
+
|
|
12
|
+
function hostOf(url) {
|
|
13
|
+
try { return new URL(String(url)).host; } catch { return "-"; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function errRes(status, msg, type = "upstream_error") {
|
|
17
|
+
return new Response(JSON.stringify({ error: { message: msg, type } }), { status, headers: { "Content-Type": "application/json" } });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function mapUpstreamError(status, detail) {
|
|
21
|
+
return { kind: status === 401 || status === 403 ? "auth" : "upstream", status, detail: String(detail || "").slice(0, 300) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 预读上限:判决恒在首帧(实测 457 字节),8 帧足够覆盖"角色帧先到、判决帧随后",
|
|
25
|
+
// 又不会把首字节延迟拖到不可控。超窗未得判决即放弃(退化为无冷却的流内 error,见 SPEC 已知上限)。
|
|
26
|
+
const PEEK_MAX_FRAMES = 8;
|
|
27
|
+
|
|
28
|
+
// 预读上游首帧只为取「流内判决」:qoder 把限流/鉴权判决裹在 HTTP 200 的 SSE 信封里
|
|
29
|
+
//(statusCodeValue:403 + serviceAvailable:false),upRes.status 恒 200,判决只有读完帧才知道。
|
|
30
|
+
// 不预读则状态码永远出不来流式路径 → 门面不冷却该号(ADR-0036 的粘号会把重试继续粘在它上面)。
|
|
31
|
+
// 返回 { res, status, frames }:res 是把预读帧回灌后的等价上游响应,frames 交回灌用。
|
|
32
|
+
// 任何异常都降级为「无判决」(status=0):排障手段不得变成故障源。
|
|
33
|
+
async function peekEnvelopeVerdict(upRes, cdbg) {
|
|
34
|
+
if (!upRes?.body) return { res: upRes, status: 0, quota: false, queued: false, frames: [] };
|
|
35
|
+
let reader;
|
|
36
|
+
try { reader = upRes.body.getReader(); } catch { return { res: upRes, status: 0, quota: false, queued: false, frames: [] }; }
|
|
37
|
+
const dec = new TextDecoder();
|
|
38
|
+
const frames = [];
|
|
39
|
+
let text = "";
|
|
40
|
+
let status = 0;
|
|
41
|
+
let quota = false;
|
|
42
|
+
let queued = false;
|
|
43
|
+
let decided = false;
|
|
44
|
+
try {
|
|
45
|
+
for (let i = 0; i < PEEK_MAX_FRAMES && !decided; i++) {
|
|
46
|
+
const { done, value } = await reader.read();
|
|
47
|
+
if (done) break;
|
|
48
|
+
if (value) { frames.push(value); text += dec.decode(value, { stream: true }); }
|
|
49
|
+
// 逐行判定:先到判决(err)或先到有效载荷即停,避免无谓等待
|
|
50
|
+
for (const raw of text.split("\n")) {
|
|
51
|
+
const line = raw.trim();
|
|
52
|
+
if (!line.startsWith("data:")) continue;
|
|
53
|
+
const p = line.slice(5).trim();
|
|
54
|
+
if (!p || p === "[DONE]") continue;
|
|
55
|
+
const d = extractDelta(p);
|
|
56
|
+
if (d.err) { status = Number(d.err.status) || 0; quota = d.err.kind === "quota"; queued = d.err.queued === true; decided = true; break; }
|
|
57
|
+
if (d.content || d.reasoning || d.toolCalls || d.usageIn || d.usageOut) { decided = true; break; }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch (e) {
|
|
61
|
+
cdbg(`[peek-throw] ${String(e?.message || e).slice(0, 160)}`);
|
|
62
|
+
}
|
|
63
|
+
cdbg(`[peek] frames=${frames.length} verdict=${status || "none(正常流)"} quota=${quota} queued=${queued}`);
|
|
64
|
+
// 回灌:已读帧先按原样吐出,再接续同一个 reader 的剩余部分
|
|
65
|
+
const body = new ReadableStream({
|
|
66
|
+
start(ctrl) {
|
|
67
|
+
for (const f of frames) { try { ctrl.enqueue(f); } catch {} }
|
|
68
|
+
(async () => {
|
|
69
|
+
try {
|
|
70
|
+
for (;;) {
|
|
71
|
+
const { done, value } = await reader.read();
|
|
72
|
+
if (done) break;
|
|
73
|
+
if (value) ctrl.enqueue(value);
|
|
74
|
+
}
|
|
75
|
+
} catch {}
|
|
76
|
+
try { ctrl.close(); } catch {}
|
|
77
|
+
})();
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
return { res: new Response(body, { status: upRes.status, headers: upRes.headers }), status, quota, queued, frames };
|
|
81
|
+
}
|
|
82
|
+
export function createChatService({ id = "qoder", fetchImpl, timeoutMs, getModelMeta } = {}) {
|
|
83
|
+
if (!fetchImpl) fetchImpl = compatFetch;
|
|
84
|
+
|
|
85
|
+
// body: OpenAI chat 请求;sess: 已建 COSY 会话;region: 该账号所属区(每号独立选端点)。返回 Response。
|
|
86
|
+
// 回显(可观测,不落凭据):x-mslxdff-upstream=本次请求的 host、x-mslxdff-qoder-region=cn|global;
|
|
87
|
+
// 管线层据此把"这次谁上的、打哪个站"投进模型日志(此前 provider 内部选号/切 URL 完全静默,排障无据)。
|
|
88
|
+
const withEcho = (res, url, region, pick) => {
|
|
89
|
+
try {
|
|
90
|
+
res.headers.set("x-mslxdff-upstream", hostOf(url));
|
|
91
|
+
res.headers.set("x-mslxdff-qoder-region", normalizeRegion(region));
|
|
92
|
+
// 账号选择决定(new/sticky/switch/forced):管线把它写进模型日志,"为什么会切号"才有据可查
|
|
93
|
+
if (pick) res.headers.set("x-mslxdff-qoder-account", String(pick));
|
|
94
|
+
} catch {}
|
|
95
|
+
return res;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// 上游状态回显(内部交接用,不落日志):流式路径按对外契约把非 200 整形成 "200 + 流内 error",
|
|
99
|
+
// 真实状态码只能靠这个头带出——否则 401/403/429/5xx 到不了 provider 门面,坏号不被冷却,
|
|
100
|
+
// 而"同请求粘号"(ADR-0036)会把后续重试继续粘在这个坏号上。
|
|
101
|
+
// Note: 流内判决需预读首帧才能拿到(HTTP 200 里裹 statusCodeValue)— 见 .agents/notes/implemented/bug-fix/2026-09-27-qoder-envelope-verdict-cooldown.md
|
|
102
|
+
const withUpstreamStatus = (res, st) => {
|
|
103
|
+
try { res.headers.set("x-mslxdff-qoder-upstream-status", String(st)); } catch {}
|
|
104
|
+
return res;
|
|
105
|
+
};
|
|
106
|
+
// 额度耗尽标记(内部交接,不落日志):门面据此换号(长冷却该号)而非普通短冷却
|
|
107
|
+
const withQuota = (res) => {
|
|
108
|
+
try { res.headers.set("x-mslxdff-qoder-quota", "1"); } catch {}
|
|
109
|
+
return res;
|
|
110
|
+
};
|
|
111
|
+
// 排队标记(内部交接,不落日志):与额度同为「换号」信号但冷却档不同——
|
|
112
|
+
// 排队是「稍后再来」,长冷却到点自动回池;额度是「今天没了」,按天重置才回。
|
|
113
|
+
const withQueued = (res) => {
|
|
114
|
+
try { res.headers.set("x-mslxdff-qoder-queued", "1"); } catch {}
|
|
115
|
+
return res;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
async function runChat(body, sess, region = "global", pick = "") {
|
|
119
|
+
const stream = body?.stream !== false;
|
|
120
|
+
const model = body?.model || "qfmodel";
|
|
121
|
+
const chatId = "chatcmpl-qoder-" + Math.random().toString(16).slice(2, 10);
|
|
122
|
+
const tools = Array.isArray(body?.tools) && body.tools.length ? body.tools : null;
|
|
123
|
+
const messages = Array.isArray(body?.messages) ? body.messages : [];
|
|
124
|
+
const maxTokens = Number(body?.max_tokens) || 0;
|
|
125
|
+
// is_reasoning 由模型目录供数(index 注入 getModelMeta,只读 peekModels 快照缓存,chat 路径恒 0 上游调用);
|
|
126
|
+
// 冷缓存/缺失一律降级 false(= 旧行为,不阻断对话)—— P0:推理标志此前从未出门。
|
|
127
|
+
let isReasoning = false;
|
|
128
|
+
try {
|
|
129
|
+
if (getModelMeta) isReasoning = (await getModelMeta(sess, normalizeRegion(region), mapModel(model)))?.is_reasoning === true;
|
|
130
|
+
} catch {}
|
|
131
|
+
// 客户端显式档位优先于目录推断(grill-decisions Q3):关断→false、其余非空→true;无档位→沿用目录结论。
|
|
132
|
+
isReasoning = isReasoningEnabled(effortFromBody(body), isReasoning);
|
|
133
|
+
|
|
134
|
+
let upRes; let reqUrl = "";
|
|
135
|
+
const echo = (res) => withEcho(res, reqUrl, region, pick);
|
|
136
|
+
// 排障日志(QODER_DEBUG_STREAM=1 开):记录出口/状态/耗时/响应头,便于对比"200 但空轮"
|
|
137
|
+
const cdbg = (...a) => { if (process.env.QODER_DEBUG_STREAM === "1") console.log("[qoder-chat]", ...a); };
|
|
138
|
+
let upMs = 0;
|
|
139
|
+
try {
|
|
140
|
+
const req = buildUpstreamRequest({ sess, region: normalizeRegion(region), model, messages, tools, maxTokens, isReasoning });
|
|
141
|
+
reqUrl = req.url;
|
|
142
|
+
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}`);
|
|
143
|
+
const t0 = Date.now();
|
|
144
|
+
upRes = await fetchImpl(req.url, {
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers: req.headers,
|
|
147
|
+
body: req.bodyStr,
|
|
148
|
+
signal: timeoutSignal(timeoutMs || 120_000),
|
|
149
|
+
});
|
|
150
|
+
upMs = Date.now() - t0;
|
|
151
|
+
cdbg(`[res] status=${upRes.status} ms=${upMs} ct=${upRes.headers.get("content-type")} host=${hostOf(reqUrl)}`);
|
|
152
|
+
} catch (e) {
|
|
153
|
+
const detail = String(e?.message || e).slice(0, 300);
|
|
154
|
+
cdbg(`[res-throw] ms=${upMs} err=${detail}`);
|
|
155
|
+
// 网络异常(超时/连接失败)折成 502:门面据此冷却该号(否则坏号恒在轮换池里)
|
|
156
|
+
if (stream) return withUpstreamStatus(echo(reshapeQoderStream(new Response("data: [DONE]\n\n"), { model, chatId })), 502);
|
|
157
|
+
return withUpstreamStatus(echo(errRes(502, detail)), 502);
|
|
158
|
+
}
|
|
159
|
+
if (upRes.status !== 200) {
|
|
160
|
+
const detail = await upRes.text().catch(() => "");
|
|
161
|
+
cdbg(`[res-non200] status=${upRes.status} body=${detail.slice(0, 300)}`);
|
|
162
|
+
const e = mapUpstreamError(upRes.status, detail);
|
|
163
|
+
if (stream) {
|
|
164
|
+
// 首包错误也走流内 error 事件(与旧 reshapeStream catch 语义一致:200 + event:error + DONE)
|
|
165
|
+
const enc = new TextEncoder();
|
|
166
|
+
const s = new ReadableStream({
|
|
167
|
+
start(c) {
|
|
168
|
+
c.enqueue(enc.encode(`data: ${JSON.stringify({ id: chatId, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), model, choices: [{ index: 0, delta: {}, finish_reason: null }] })}\n\n`));
|
|
169
|
+
c.enqueue(enc.encode(`event: error\ndata: ${JSON.stringify({ message: e.detail, type: e.kind })}\n\ndata: [DONE]\n\n`));
|
|
170
|
+
c.close();
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
// 对外仍是 200(契约不变),真实状态码经回显头交门面决定是否冷却
|
|
174
|
+
return withUpstreamStatus(echo(new Response(s, { status: 200, headers: { "Content-Type": "text/event-stream" } })), upRes.status);
|
|
175
|
+
}
|
|
176
|
+
return withUpstreamStatus(echo(errRes(errorStatus(e), e.detail, e.kind)), upRes.status);
|
|
177
|
+
}
|
|
178
|
+
if (stream) {
|
|
179
|
+
// 预读首帧取流内判决(qoder 把 403/429 裹在 200 的信封里),预读帧原样回灌给整形器。
|
|
180
|
+
// 判决状态码经 withUpstreamStatus 出门面 → index.js 据此 ring.onError 冷却该号 →
|
|
181
|
+
// 粘号选择器下次因 isCooling 为真而换号。对外仍恒 200(契约不变)。
|
|
182
|
+
const peek = await peekEnvelopeVerdict(upRes, cdbg);
|
|
183
|
+
let shaped = echo(reshapeQoderStream(peek.res, { model, chatId, prefetched: peek.frames }));
|
|
184
|
+
// 只在真有判决时挂状态码:正常流不写这个头(index.js 靠它区分"要不要冷却")
|
|
185
|
+
if (peek.status) shaped = withUpstreamStatus(shaped, peek.status);
|
|
186
|
+
if (peek.quota) shaped = withQuota(shaped);
|
|
187
|
+
// 排队旗标(内部交接):门面据此走队列档冷却并在同一请求内换号,别把排队号送回客户端
|
|
188
|
+
if (peek.queued) shaped = withQueued(shaped);
|
|
189
|
+
return shaped;
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
const agg = await aggregateQoderStream(upRes);
|
|
193
|
+
const out = toCompletionJson({ model, chatId, ...agg });
|
|
194
|
+
return echo(new Response(JSON.stringify(out), { status: 200, headers: { "Content-Type": "application/json" } }));
|
|
195
|
+
} catch (e) {
|
|
196
|
+
const out = echo(errRes(errorStatus(e), String(e?.detail || e?.message || e).slice(0, 300), e?.kind || "upstream_error"));
|
|
197
|
+
const marked = e?.kind === "quota" ? withQuota(out) : out;
|
|
198
|
+
return e?.queued ? withQueued(marked) : marked;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { runChat };
|
|
203
|
+
}
|