mslxdff 0.1.232 → 0.1.233
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/auto.js +10 -0
- package/src/chat-pipeline/index.js +11 -1
- package/src/model-trace.js +10 -2
- package/src/models.js +91 -24
- package/src/providers/qoder/account-store.js +18 -0
- package/src/providers/qoder/chat.js +108 -19
- package/src/providers/qoder/checkin.js +25 -12
- package/src/providers/qoder/index.js +62 -0
- package/src/providers/qoder/models.js +64 -2
- package/src/providers/qoder/oauth.js +42 -1
- package/src/providers/qoder/payload.js +116 -80
- package/src/providers/qoder/request.js +7 -2
- package/src/providers/qoder/session.js +26 -10
- package/src/providers/qoder/sse.js +100 -12
- package/src/providers/qoder/sticky.js +5 -2
- package/src/providers/qoder/wire-messages.js +242 -0
- package/src/providers/raccoon/chat.js +1 -1
- package/src/providers/raccoon/const.js +21 -4
- package/src/providers/raccoon/index.js +15 -2
- package/src/providers/raccoon/models.js +68 -17
- package/src/routes/stream.js +28 -11
|
@@ -14,6 +14,8 @@ import { newSession } from "./session.js";
|
|
|
14
14
|
import { createChatService } from "./chat.js";
|
|
15
15
|
import { createStickyPicker } from "./sticky.js";
|
|
16
16
|
import { createModelsService } from "./models.js";
|
|
17
|
+
import { refreshDeviceToken } from "./oauth.js";
|
|
18
|
+
import { updateQoderAccountTokens } from "./account-store.js";
|
|
17
19
|
import { compatFetch } from "../../compat.js";
|
|
18
20
|
|
|
19
21
|
function buildSessionFor(cred) {
|
|
@@ -41,6 +43,8 @@ export function createQoderProvider({
|
|
|
41
43
|
// 排队(10605/isQueued)单独一档:队列没放行前反复送回就是白烧(实测跨小时不恢复),默认 5min。
|
|
42
44
|
// 上游口播的 retryAfterSeconds=30 只当参考值,不当本档上限——按 30s 回池等于回到旧行为。
|
|
43
45
|
queueCooldownMs = envInt("MSLXDFF_QODER_QUEUE_COOLDOWN_MS", 300_000),
|
|
46
|
+
// 登录过期且 refresh 也作废 = 只能人肉重登:短冷却等于每 30s 拿死 refresh 再撞一次,默认 1h。
|
|
47
|
+
authGoneCooldownMs = envInt("MSLXDFF_QODER_AUTHGONE_COOLDOWN_MS", 3600_000),
|
|
44
48
|
connectTimeoutMs = Number(process.env.MSLXDFF_QODER_TIMEOUT_MS) || 120_000,
|
|
45
49
|
} = {}) {
|
|
46
50
|
const keys = (() => {
|
|
@@ -107,6 +111,38 @@ export function createQoderProvider({
|
|
|
107
111
|
// 额度错在 provider 内直接换号重发(不依赖外层空转重试链路:那条链路在非流式路径不触发)。
|
|
108
112
|
// 收口只认「每个号都被上游明确判过额度」;排队/限流等非额度冷却不得谎称没额度
|
|
109
113
|
// Note: 判定收紧(全号才报 quota_exhausted,其余如实返回/报冷却中)— 见 .agents/notes/implemented/bug-fix/2026-09-28-qoder-quota-cooldown-switch.md
|
|
114
|
+
// 登录过期(105)不冷却换号轮询:deviceToken 时效短,官方 CLI 临期即刷——
|
|
115
|
+
// 刷成功 = 用新 token 对原地重发(uid 不变、region 不变);刷失败(refresh 也作废)才长冷却该号并如实报错。
|
|
116
|
+
// 全池过期时逐号刷新(每号各刷各的),全部刷不动才给「重登」终态。
|
|
117
|
+
const refreshedUids = new Set(); // 本进程已刷过的 uid:防同号反复撞刷新端点(refresh 一次性,失败即真死)
|
|
118
|
+
async function refreshAndRetry(stripped, picked, pickedKey, scope) {
|
|
119
|
+
const blob = accountFromBlob(pickedKey);
|
|
120
|
+
const auth = authList.find((a) => String(a?.uid || "") === String(picked.sess?.identity?.uid || ""));
|
|
121
|
+
const uid = picked.sess?.identity?.uid || auth?.uid || "";
|
|
122
|
+
const rt = blob?.refreshToken || auth?.refreshToken || "";
|
|
123
|
+
if (!uid || refreshedUids.has(`${uid}`)) return null; // 已刷过仍 105 → refresh 作废,交给冷却路径
|
|
124
|
+
refreshedUids.add(String(uid));
|
|
125
|
+
try {
|
|
126
|
+
const pair = await refreshDeviceToken({ region: picked.region, refreshToken: rt, fetchImpl });
|
|
127
|
+
const newBlob = JSON.stringify({ device_token: pair.deviceToken, refresh_token: pair.refreshToken });
|
|
128
|
+
ring.replace(pickedKey, newBlob);
|
|
129
|
+
// auths 行的 refreshToken 同步(authList 是本进程缓存)
|
|
130
|
+
if (auth) auth.refreshToken = pair.refreshToken;
|
|
131
|
+
// 落盘失败不连累已成功的刷新(ring.replace 已在内存生效,本次重发用新 token)——
|
|
132
|
+
// 进程内即时自愈;只是没写穿 auths 文档/state,重启后会退回旧 blob(那时再撞 105 再刷)。
|
|
133
|
+
await updateQoderAccountTokens({ uid, deviceToken: pair.deviceToken, refreshToken: pair.refreshToken }).catch(() => {});
|
|
134
|
+
pickKey.forget(scope); // 旧 key 已被 replace:粘号表里的旧 blob 引用必须丢弃
|
|
135
|
+
// 原地重建会话并重发一次(新号 = 新 identity token)
|
|
136
|
+
const sess2 = buildSessionFor({ ...pair, uid, name: auth?.name || "", region: picked.region });
|
|
137
|
+
return await chatSvc.runChat(stripped, sess2, picked.region, "refreshed");
|
|
138
|
+
} catch (e) {
|
|
139
|
+
// 刷新不可重试(refresh 作废/返回不完整)→ 长冷却该号;可重试(网络)→ 短冷却交外层
|
|
140
|
+
const coolMs = e?.retryable ? cooldownMs : authGoneCooldownMs;
|
|
141
|
+
try { ring.onError(pickedKey, coolMs); } catch {}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
110
146
|
async function chat(body, opts) {
|
|
111
147
|
// scope=reqId:同一次客户端请求的多次上游调用(空转重试)粘同一个号
|
|
112
148
|
const scope = opts?.reqId ? `req:${opts.reqId}` : null;
|
|
@@ -141,6 +177,32 @@ export function createQoderProvider({
|
|
|
141
177
|
const ust = Number(res?.headers?.get?.("x-mslxdff-qoder-upstream-status")) || 0;
|
|
142
178
|
const isQuota = res?.headers?.get?.("x-mslxdff-qoder-quota") === "1";
|
|
143
179
|
const isQueued = res?.headers?.get?.("x-mslxdff-qoder-queued") === "1";
|
|
180
|
+
const isLogin = res?.headers?.get?.("x-mslxdff-qoder-login") === "1";
|
|
181
|
+
const isNoCapacity = res?.headers?.get?.("x-mslxdff-qoder-nocapacity") === "1";
|
|
182
|
+
// 判决出口无条件留痕(2026-10-22 用户实证「没多打日志→排查全靠猜」):只要任一判决旗标挂上,
|
|
183
|
+
// 就把「门面实际拿到的 res.status + 各旗标」打到 daemon 控制台。离线复现 429 与真机 403 的落差,
|
|
184
|
+
// 只有这一行能一锤定音——是 provider 真没返回 429,还是返回了却被下游 relay 改回原状态。
|
|
185
|
+
// 只在有判决时打(正常成功流零噪音),量极小。
|
|
186
|
+
if (isQuota || isQueued || isLogin || isNoCapacity) {
|
|
187
|
+
console.log(`[qoder-facade] hop=${hop} status=${res?.status} ust=${ust} quota=${isQuota} queued=${isQueued} login=${isLogin} nocap=${isNoCapacity} retryAfter=${res?.headers?.get?.("x-mslxdff-qoder-retry-after") ?? "-"}`);
|
|
188
|
+
}
|
|
189
|
+
// 无容量(10605 + queueCount:0 + serviceAvailable:false):整池同档共享同一个 p3 队列,
|
|
190
|
+
// 换号 = 把整池对同一堵墙各撞一遍(实测 5.4s 白烧 + 客户端仍收 403)。冷却本号防下一请求立刻
|
|
191
|
+
// 再选它,但**不换号轮询**,带 Retry-After 的判决如实透给客户端按秒重试(对齐 magpie 503 语义)。
|
|
192
|
+
if (isNoCapacity) {
|
|
193
|
+
try { ring.onError(pickedKey, queueCooldownMs); } catch {}
|
|
194
|
+
lastRes = res;
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
// 登录过期(105):先刷新令牌原地重发,绝不当普通 401 冷却换号(否则把整池过期号轮一遍、
|
|
198
|
+
// 每号还叠空转重试白烧)。刷成功带出新响应直接返回;刷失败已在 refreshAndRetry 内冷却该号,换下号。
|
|
199
|
+
if (isLogin) {
|
|
200
|
+
const retried = await refreshAndRetry(stripped, picked, pickedKey, scope);
|
|
201
|
+
if (retried) return retried;
|
|
202
|
+
lastRes = res;
|
|
203
|
+
if (ring.available() > 0) continue;
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
144
206
|
const bad = badAuth(st) ? st : (badAuth(ust) ? ust : 0);
|
|
145
207
|
// 冷却三档:额度(按天重置,最长)> 排队(队列没放行前别再送回,默认 5min)> 其它(普通短冷却)。
|
|
146
208
|
// 排队单独成档的依据:现网 31h 窗口 global 区 176 发被判决 129 发且跨小时不复现恢复,
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
// qoder 模型服务(对齐官方 qodercli + 外部参考 magpie qoder 插件/CLIProxyAPI):
|
|
2
|
+
// 原生 COSY 签名拉 /model/list,10min 缓存(按 region 分桶);
|
|
3
|
+
// 只暴露 enable=true 的可调用模型(enable=false 调对话必挂,不展示)。
|
|
4
|
+
// context_config(多窗口 200K/400K/1M)与 thinking_config(推理档位)随目录带出,
|
|
5
|
+
// 供 payload 选窗口/档位、供能力层展示——旧版只留 max_input_tokens,拿不到多窗口是"不好用"的一环。
|
|
3
6
|
import { compatFetch, timeoutSignal } from "../../compat.js";
|
|
4
7
|
import { getEndpoints, normalizeRegion } from "./constants.js";
|
|
5
8
|
import { buildCosyHeaders, pathSigFrom } from "./session.js";
|
|
@@ -9,10 +12,57 @@ const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
|
9
12
|
const CREATED = 1753600000;
|
|
10
13
|
const OWNER = "qoder";
|
|
11
14
|
|
|
15
|
+
const positive = (v) => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : undefined);
|
|
16
|
+
|
|
17
|
+
// context_config → { windows:[...升序], defaultWindow }(Qoder 的 Context 设置:200K/400K/1M)
|
|
18
|
+
function parseContextConfig(value) {
|
|
19
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
|
|
20
|
+
const windows = [];
|
|
21
|
+
let defaultWindow;
|
|
22
|
+
for (const raw of Object.values(value)) {
|
|
23
|
+
if (typeof raw !== "object" || raw === null) continue;
|
|
24
|
+
const tc = positive(raw.token_count);
|
|
25
|
+
if (tc === undefined) continue;
|
|
26
|
+
windows.push(tc);
|
|
27
|
+
if (raw.is_default === true) defaultWindow = tc;
|
|
28
|
+
}
|
|
29
|
+
windows.sort((a, b) => a - b);
|
|
30
|
+
return {
|
|
31
|
+
...(windows.length ? { windows } : {}),
|
|
32
|
+
...(defaultWindow !== undefined ? { defaultWindow } : {}),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// thinking_config → { efforts:[...], defaultEffort, enabled }(推理档位与是否可关思考)
|
|
37
|
+
const EFFORT_ORDER = ["low", "medium", "high", "xhigh", "max"];
|
|
38
|
+
function parseThinkingConfig(value) {
|
|
39
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
|
|
40
|
+
const out = {};
|
|
41
|
+
const isDefault = (e) => typeof e === "object" && e !== null && e.is_default === true;
|
|
42
|
+
const enabled = value.enabled && typeof value.enabled === "object" ? value.enabled : null;
|
|
43
|
+
if (enabled) {
|
|
44
|
+
out.thinks = true;
|
|
45
|
+
const raw = typeof enabled.efforts === "object" && enabled.efforts !== null && !Array.isArray(enabled.efforts) ? enabled.efforts : null;
|
|
46
|
+
if (raw) {
|
|
47
|
+
const efforts = Object.keys(raw).filter((k) => k.trim());
|
|
48
|
+
efforts.sort((a, b) => (EFFORT_ORDER.indexOf(a) < 0 ? 99 : EFFORT_ORDER.indexOf(a)) - (EFFORT_ORDER.indexOf(b) < 0 ? 99 : EFFORT_ORDER.indexOf(b)));
|
|
49
|
+
if (efforts.length) out.efforts = efforts;
|
|
50
|
+
for (const [k, v] of Object.entries(raw)) if (isDefault(v)) out.defaultEffort = k;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (value.disabled && typeof value.disabled === "object") out.alwaysThinks = false;
|
|
54
|
+
// enabled.is_default 与 disabled.is_default 都为 true/都 false → 上游给的是"默认开还是默认关"
|
|
55
|
+
if (enabled && isDefault(value.disabled)) out.thinkDefault = false;
|
|
56
|
+
else if (enabled && isDefault(enabled)) out.thinkDefault = true;
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
12
60
|
export function extractModels(chatList) {
|
|
13
61
|
const out = [];
|
|
14
62
|
for (const m of Array.isArray(chatList) ? chatList : []) {
|
|
15
63
|
if (!m?.key) continue;
|
|
64
|
+
const ctx = parseContextConfig(m.context_config);
|
|
65
|
+
const think = parseThinkingConfig(m.thinking_config);
|
|
16
66
|
out.push({
|
|
17
67
|
id: m.key,
|
|
18
68
|
name: String(m.display_name || m.key),
|
|
@@ -20,12 +70,18 @@ export function extractModels(chatList) {
|
|
|
20
70
|
isDefault: Boolean(m.is_default),
|
|
21
71
|
isReasoning: Boolean(m.is_reasoning),
|
|
22
72
|
contextLength: Number(m.max_input_tokens) || 0,
|
|
73
|
+
maxOutputTokens: positive(m.max_output_tokens),
|
|
23
74
|
priceFactor: Number(m.price_factor) || 0,
|
|
75
|
+
isVl: m.is_vl === true,
|
|
76
|
+
source: m.source || "system",
|
|
77
|
+
...ctx,
|
|
78
|
+
...think,
|
|
24
79
|
});
|
|
25
80
|
}
|
|
26
81
|
return out;
|
|
27
82
|
}
|
|
28
83
|
|
|
84
|
+
|
|
29
85
|
export function createModelsService({ id = "qoder", fetchImpl, clock = Date.now } = {}) {
|
|
30
86
|
let cache = null, fetchedAt = 0;
|
|
31
87
|
|
|
@@ -59,6 +115,12 @@ export function createModelsService({ id = "qoder", fetchImpl, clock = Date.now
|
|
|
59
115
|
enable: true,
|
|
60
116
|
is_reasoning: m.isReasoning,
|
|
61
117
|
price_factor: m.priceFactor,
|
|
118
|
+
// 供 payload 选窗口/档位、能力层展示(随目录带出,不进 /v1/models 也无害,额外字段客户端忽略)
|
|
119
|
+
...(m.windows ? { context_windows: m.windows } : {}),
|
|
120
|
+
...(m.defaultWindow ? { default_window: m.defaultWindow } : {}),
|
|
121
|
+
...(m.efforts ? { reasoning_efforts: m.efforts } : {}),
|
|
122
|
+
...(m.defaultEffort ? { default_reasoning_effort: m.defaultEffort } : {}),
|
|
123
|
+
...(m.maxOutputTokens ? { max_output_tokens: m.maxOutputTokens } : {}),
|
|
62
124
|
}));
|
|
63
125
|
cacheByRegion.set(normRegion, { list, at: now });
|
|
64
126
|
// 兼容旧单测 _getCache:默认读 global 桶
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// 3. fetchUserInfo: GET /api/v1/userinfo(Bearer dt-xxx)→ uid/name/organization_id
|
|
5
5
|
import { randomBytes, createHash } from "node:crypto";
|
|
6
6
|
import { compatFetch, timeoutSignal } from "../../compat.js";
|
|
7
|
-
import { OAUTH_CLIENT_ID, getEndpoints } from "./constants.js";
|
|
7
|
+
import { OAUTH_CLIENT_ID, getEndpoints, openapiBase } from "./constants.js";
|
|
8
8
|
|
|
9
9
|
const POLL_MS = 3000;
|
|
10
10
|
export const POLL_TIMEOUT_MS = 10 * 60 * 1000; // 对齐 qoder2api 10 分钟
|
|
@@ -82,4 +82,45 @@ export async function waitForAuth({ region, nonce, verifier, fetchImpl = compatF
|
|
|
82
82
|
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
83
83
|
try { process.stdout.write("."); } catch {}
|
|
84
84
|
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// device token 续期(对齐官方 CLI + magpie refreshDevice):deviceToken 时效短(约 1 天),
|
|
88
|
+
// 官方在收到 105 Login expired 时用 refresh_token 换全新的一对(旧 refresh_token 一次性,换到即弃)。
|
|
89
|
+
// POST {openapiBase}/api/v1/deviceToken/refresh body{refresh_token} → { token|device_token, refresh_token }。
|
|
90
|
+
// 只带 refresh_token 认证(过期 deviceToken 无需当 Bearer);401/403 = refresh 也被拒 → 真需重登。
|
|
91
|
+
// 抛错语义:{ retryable } 标记——网络/5xx 可稍后再试,4xx(refresh 作废)不可重试。
|
|
92
|
+
export async function refreshDeviceToken({ region = "global", refreshToken, fetchImpl = compatFetch } = {}) {
|
|
93
|
+
const rt = String(refreshToken || "").trim();
|
|
94
|
+
if (!rt) { const e = new Error("无 refresh_token,需重新登录"); e.retryable = false; throw e; }
|
|
95
|
+
const url = `${openapiBase(region)}/api/v1/deviceToken/refresh`;
|
|
96
|
+
let res;
|
|
97
|
+
try {
|
|
98
|
+
res = await fetchImpl(url, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: { accept: "application/json", "content-type": "application/json" },
|
|
101
|
+
body: JSON.stringify({ refresh_token: rt }),
|
|
102
|
+
signal: timeoutSignal(20000),
|
|
103
|
+
});
|
|
104
|
+
} catch (e) {
|
|
105
|
+
const err = new Error(`deviceToken 刷新请求失败: ${String(e?.message || e).slice(0, 160)}`);
|
|
106
|
+
err.retryable = true; throw err;
|
|
107
|
+
}
|
|
108
|
+
const txt = await res.text().catch(() => "");
|
|
109
|
+
if (res.status === 401 || res.status === 403) {
|
|
110
|
+
const err = new Error(`deviceToken 刷新被拒 HTTP ${res.status}(refresh_token 已作废,需重新登录)`);
|
|
111
|
+
err.retryable = false; throw err;
|
|
112
|
+
}
|
|
113
|
+
if (!res.ok) {
|
|
114
|
+
const err = new Error(`deviceToken 刷新上游 HTTP ${res.status}: ${txt.slice(0, 160)}`);
|
|
115
|
+
err.retryable = true; throw err;
|
|
116
|
+
}
|
|
117
|
+
let j;
|
|
118
|
+
try { j = JSON.parse(txt); } catch { const err = new Error(`deviceToken 刷新返回非 JSON: ${txt.slice(0, 160)}`); err.retryable = false; throw err; }
|
|
119
|
+
const deviceToken = String(j.token || j.device_token || "").trim();
|
|
120
|
+
const newRefresh = String(j.refresh_token || j.refreshToken || "").trim();
|
|
121
|
+
if (!deviceToken || !newRefresh) {
|
|
122
|
+
const err = new Error("deviceToken 刷新返回不完整 token 对(缺 token 或 refresh_token)");
|
|
123
|
+
err.retryable = false; throw err;
|
|
124
|
+
}
|
|
125
|
+
return { deviceToken, refreshToken: newRefresh };
|
|
85
126
|
}
|
|
@@ -1,13 +1,28 @@
|
|
|
1
|
-
// qoder
|
|
2
|
-
//
|
|
1
|
+
// qoder 请求体装配(对齐官方 qodercli 线格式;外部参考:dsh-qoder-connect wire/serialize.ts、magpie qoder 插件)。
|
|
2
|
+
// 消息形状转换层(OpenAI→Qoder wire messages / 会话指纹 / 缓存标记 / tool 配对)已拆至 wire-messages.js
|
|
3
|
+
// (体积门 + 职责边界)。本文件只管:
|
|
4
|
+
// - 模板外壳加载(baseprompt.json 只作 parameters/model_config 骨架,messages/tools 一律重建);
|
|
5
|
+
// - business.id = request_set_id(上游把排队条目与 business id 绑定,各发一个 UUID 会让排队/回放关联失效);
|
|
6
|
+
// - business.product=cli(我们伪装的是 qodercli,不是 IDE);
|
|
7
|
+
// - 思考档位/上下文窗口按模型目录(meta)补写;模型名映射 mapModel。
|
|
3
8
|
import { readFileSync } from "node:fs";
|
|
4
9
|
import { join, dirname } from "node:path";
|
|
5
10
|
import { fileURLToPath } from "node:url";
|
|
6
11
|
import { newUUID, unixMs } from "./fingerprint.js";
|
|
12
|
+
import {
|
|
13
|
+
normalizeContent, buildUserMessage, buildStructuredMessage,
|
|
14
|
+
toQoderWireMessages, conversationSessionId, cacheMark,
|
|
15
|
+
} from "./wire-messages.js";
|
|
16
|
+
|
|
17
|
+
// 转换层符号 re-export(外部 import 路径保持 payload.js 不变;新代码建议直接 import wire-messages.js)
|
|
18
|
+
export {
|
|
19
|
+
blankResponseMeta, buildUserMessage, buildStructuredMessage, normalizeContent,
|
|
20
|
+
withQoderPersona, toQoderWireMessages, conversationSessionId, cacheMark,
|
|
21
|
+
} from "./wire-messages.js";
|
|
7
22
|
|
|
8
23
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
24
|
|
|
10
|
-
// 模板单例:进程内加载一次(替换占位符后 JSON.parse
|
|
25
|
+
// 模板单例:进程内加载一次(替换占位符后 JSON.parse)
|
|
11
26
|
let _template = null;
|
|
12
27
|
export function loadTemplate(templatePath) {
|
|
13
28
|
if (_template && !templatePath) return _template;
|
|
@@ -18,56 +33,113 @@ export function loadTemplate(templatePath) {
|
|
|
18
33
|
return _template;
|
|
19
34
|
}
|
|
20
35
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
contents: [{ type: "text", text: String(text || "") }],
|
|
37
|
-
response_meta: blankResponseMeta(),
|
|
38
|
-
reasoning_content_signature: "",
|
|
39
|
-
};
|
|
36
|
+
// 档位对齐(参考 magpie effortFor):把客户端要档位映射到模型声明档位集合里的最近一档(平手取高)。
|
|
37
|
+
const EFFORT_RANK = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"];
|
|
38
|
+
export function fitEffort(want, levels) {
|
|
39
|
+
const w = String(want || "").toLowerCase();
|
|
40
|
+
if (!Array.isArray(levels) || !levels.length || levels.includes(w)) return w;
|
|
41
|
+
const at = EFFORT_RANK.indexOf(w);
|
|
42
|
+
if (at < 0) return w;
|
|
43
|
+
let best = w, dist = EFFORT_RANK.length;
|
|
44
|
+
for (const l of levels) {
|
|
45
|
+
const i = EFFORT_RANK.indexOf(l);
|
|
46
|
+
if (i < 0 || l === "none") continue;
|
|
47
|
+
const d = Math.abs(i - at);
|
|
48
|
+
if (d < dist || (d === dist && i > at)) { best = l; dist = d; }
|
|
49
|
+
}
|
|
50
|
+
return best;
|
|
40
51
|
}
|
|
41
52
|
|
|
42
|
-
|
|
43
|
-
|
|
53
|
+
// 上下文窗口选择(参考 magpie windowFor):请求装得下默认窗口就用默认;
|
|
54
|
+
// 装不下按能容纳的最小窗口升档(bytes/3 粗估 + 预留回复长度),全装不下给最大档。
|
|
55
|
+
export function pickContextWindow(need, meta) {
|
|
56
|
+
const base = Number(meta?.defaultWindow) || Number(meta?.context_length) || 0;
|
|
57
|
+
const windows = Array.isArray(meta?.windows) ? meta.windows : [];
|
|
58
|
+
if (base > 0 && need <= base) return base;
|
|
59
|
+
return windows.find((w) => w >= need) ?? (windows.length ? windows[windows.length - 1] : 0);
|
|
44
60
|
}
|
|
45
61
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
62
|
+
// 组装上游请求体。template 外壳字段(parameters/model_config 骨架等)保留,
|
|
63
|
+
// messages/tools/ids/chat_context/business 一律重建。
|
|
64
|
+
// meta:模型目录行(context_windows/default_window/reasoning_efforts/default_reasoning_effort/max_output_tokens);
|
|
65
|
+
// effort:客户端声明的思考档位原始串(可空)。
|
|
66
|
+
export function buildQoderBody({ template, userId, userType, model, messages, tools, maxTokens, isReasoning, meta, effort, preserveTemplateHistory = false }) {
|
|
67
|
+
const tmpl = template || loadTemplate();
|
|
68
|
+
const body = JSON.parse(JSON.stringify(tmpl));
|
|
69
|
+
const nid = newUUID();
|
|
70
|
+
const setId = newUUID();
|
|
71
|
+
body.request_id = nid;
|
|
72
|
+
body.chat_record_id = nid;
|
|
73
|
+
body.request_set_id = setId;
|
|
74
|
+
body.stream = true;
|
|
75
|
+
body.aliyun_user_type = userType || "personal_standard";
|
|
76
|
+
if (body.model_config) {
|
|
77
|
+
body.model_config.key = model;
|
|
78
|
+
if (isReasoning) body.model_config.is_reasoning = true;
|
|
79
|
+
}
|
|
80
|
+
// 推理标志两处镜像写(对齐官方 CLI 请求形状),只写 model_config 上游不吐 reasoning_content
|
|
81
|
+
if (isReasoning && body.chat_context?.extra?.modelConfig) {
|
|
82
|
+
body.chat_context.extra.modelConfig.is_reasoning = true;
|
|
83
|
+
body.chat_context.extra.modelConfig.key = model; // 与 model_config.key 同步,不留模板 "auto"
|
|
84
|
+
}
|
|
85
|
+
const wireMessages = cacheMark(preserveTemplateHistory
|
|
86
|
+
? buildQoderMessages(tmpl, messages, Array.isArray(tools) && tools.length > 0) // 回退通道:MSLXDFF_QODER_LEGACY_WIRE=1
|
|
87
|
+
: toQoderWireMessages(messages));
|
|
88
|
+
body.session_id = conversationSessionId(userId, model, wireMessages);
|
|
89
|
+
const outCap = maxTokens > 0 ? maxTokens : (Number(meta?.max_output_tokens) || 0);
|
|
90
|
+
if (outCap > 0 && body.parameters) body.parameters.max_tokens = outCap;
|
|
91
|
+
// 思考参数按真值口径补写(models.js 目录行为 snake_case):enable_thinking + 命中模型声明档位才发 reasoning_effort。
|
|
92
|
+
// 只在模型确实支持时补写,避免把未知参数/无效档位打给不认的模型(fitEffort 兜底后仍须 ∈ efforts 才发)。
|
|
93
|
+
if (body.parameters) {
|
|
94
|
+
const efforts = Array.isArray(meta?.reasoning_efforts) ? meta.reasoning_efforts : [];
|
|
95
|
+
const want = String(effort || "").trim().toLowerCase();
|
|
96
|
+
if (isReasoning) {
|
|
97
|
+
body.parameters.enable_thinking = true;
|
|
98
|
+
if (efforts.length) {
|
|
99
|
+
const cand = want && EFFORT_RANK.includes(want)
|
|
100
|
+
? fitEffort(want, efforts)
|
|
101
|
+
: String(meta?.default_reasoning_effort || efforts[0] || "");
|
|
102
|
+
if (efforts.includes(cand)) body.parameters.reasoning_effort = cand;
|
|
103
|
+
}
|
|
104
|
+
} else if (want === "none" && efforts.length) {
|
|
105
|
+
body.parameters.enable_thinking = false; // 模型可关思考才发关;不可关的(无 efforts)保持默认不干预
|
|
106
|
+
}
|
|
107
|
+
// 上下文窗口升档(仅模型声明了多窗口才发)
|
|
108
|
+
const windows = Array.isArray(meta?.context_windows) ? meta.context_windows : [];
|
|
109
|
+
if (windows.length) {
|
|
110
|
+
const need = Math.ceil(JSON.stringify({ m: wireMessages, t: tools || [] }).length / 3) + (outCap || 32000);
|
|
111
|
+
const w = pickContextWindow(need, { defaultWindow: meta.default_window, context_length: meta.context_length, windows });
|
|
112
|
+
if (w > 0) body.parameters.context_length = w;
|
|
54
113
|
}
|
|
55
|
-
return out;
|
|
56
114
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
115
|
+
// chat_context 的 text/originalContent 是纯字符串(旧模板形状是 {type,text} 对象——官方从不发对象形状)
|
|
116
|
+
const prompt = (() => {
|
|
117
|
+
for (let i = wireMessages.length - 1; i >= 0; i--) {
|
|
118
|
+
const m = wireMessages[i];
|
|
119
|
+
if (m?.role === "user") return normalizeContent(m.contents?.map((p) => p.text ?? "").join("\n\n") || m.content);
|
|
120
|
+
}
|
|
121
|
+
return "";
|
|
122
|
+
})();
|
|
123
|
+
if (body.business) {
|
|
124
|
+
body.business.id = setId; // 排队条目与 business.id 绑定,必须 = request_set_id
|
|
125
|
+
body.business.product = "cli";
|
|
126
|
+
body.business.version = "1.0.0";
|
|
127
|
+
body.business.begin_at = unixMs();
|
|
128
|
+
body.business.name = prompt.length > 30 ? prompt.slice(0, 30) : prompt;
|
|
129
|
+
}
|
|
130
|
+
if (body.chat_context) {
|
|
131
|
+
body.chat_context.text = prompt;
|
|
132
|
+
if (body.chat_context.extra) body.chat_context.extra.originalContent = prompt;
|
|
65
133
|
}
|
|
66
|
-
|
|
134
|
+
body.messages = wireMessages;
|
|
135
|
+
// tools 空数组契约:模板内嵌的 14 个 Qoder CLI 工具永不出门;无工具时上游官方亦发 []
|
|
136
|
+
body.tools = Array.isArray(tools) && tools.length ? tools : [];
|
|
137
|
+
return { body, prompt, mcSource: body.model_config?.source || "system" };
|
|
67
138
|
}
|
|
68
139
|
|
|
69
|
-
// OpenAI messages → Qoder messages
|
|
70
|
-
//
|
|
140
|
+
// OpenAI messages → Qoder messages(旧版形状:无 system 前置模板 system;user=contents[];
|
|
141
|
+
// assistant tool_calls 透传;含 tool_result 数组拆成 tool 行)。
|
|
142
|
+
// 仅保留给 MSLXDFF_QODER_LEGACY_WIRE=1 回退通道;正常路径用 wire-messages.js 的 toQoderWireMessages。
|
|
71
143
|
export function buildQoderMessages(template, incoming, toolsEnabled) {
|
|
72
144
|
const rebuilt = [];
|
|
73
145
|
const hasSystem = (incoming || []).some((m) => m?.role === "system");
|
|
@@ -105,42 +177,6 @@ export function buildQoderMessages(template, incoming, toolsEnabled) {
|
|
|
105
177
|
return rebuilt;
|
|
106
178
|
}
|
|
107
179
|
|
|
108
|
-
// 组装上游请求体(仿 CallQoderWithOpts)
|
|
109
|
-
export function buildQoderBody({ template, userType, model, messages, tools, maxTokens, isReasoning }) {
|
|
110
|
-
const body = JSON.parse(JSON.stringify(template || loadTemplate()));
|
|
111
|
-
const nid = newUUID();
|
|
112
|
-
body.request_id = nid;
|
|
113
|
-
body.chat_record_id = nid;
|
|
114
|
-
body.request_set_id = newUUID();
|
|
115
|
-
body.session_id = newUUID();
|
|
116
|
-
body.stream = true;
|
|
117
|
-
body.aliyun_user_type = userType || "personal_standard";
|
|
118
|
-
if (body.model_config) {
|
|
119
|
-
body.model_config.key = model;
|
|
120
|
-
if (isReasoning) body.model_config.is_reasoning = true;
|
|
121
|
-
}
|
|
122
|
-
// 推理标志两处镜像写(对齐官方 CLI 请求形状),只写 model_config 上游不吐 reasoning_content
|
|
123
|
-
if (isReasoning && body.chat_context?.extra?.modelConfig) {
|
|
124
|
-
body.chat_context.extra.modelConfig.is_reasoning = true;
|
|
125
|
-
body.chat_context.extra.modelConfig.key = model; // 与 model_config.key 同步,不留模板 "auto"(评审路1 P1#5)
|
|
126
|
-
}
|
|
127
|
-
if (maxTokens > 0 && body.parameters) body.parameters.max_tokens = maxTokens;
|
|
128
|
-
const prompt = extractLatestUserPrompt(messages);
|
|
129
|
-
if (body.business) {
|
|
130
|
-
body.business.id = newUUID();
|
|
131
|
-
body.business.begin_at = unixMs();
|
|
132
|
-
body.business.name = prompt.length > 30 ? prompt.slice(0, 30) : prompt;
|
|
133
|
-
}
|
|
134
|
-
if (body.chat_context) {
|
|
135
|
-
if (body.chat_context.text) body.chat_context.text.text = prompt;
|
|
136
|
-
if (body.chat_context.extra?.originalContent) body.chat_context.extra.originalContent.text = prompt;
|
|
137
|
-
}
|
|
138
|
-
body.messages = messages;
|
|
139
|
-
// tools 空数组契约:模板内嵌的 14 个 Qoder CLI 工具永不出门;无工具时上游官方亦发 []
|
|
140
|
-
body.tools = Array.isArray(tools) && tools.length ? tools : [];
|
|
141
|
-
return { body, prompt, mcSource: body.model_config?.source || "system" };
|
|
142
|
-
}
|
|
143
|
-
|
|
144
180
|
// 客户端模型名 → 上游 key(仿 defaultModelMapping 双向 substring,家族关键字兜底)
|
|
145
181
|
const DEFAULT_MAPPING = {
|
|
146
182
|
opus: "qmodel_38max", sonnet: "gmodel", haiku: "qfmodel",
|
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
// qoder 上游请求装配(纯函数,无网络):OpenAI body → COSY 签名请求(url/headers/bodyStr)。
|
|
2
2
|
// 从 chat.js 拆出,使真流式/聚合两条管线共享同一装配口(单一来源,防两处签名漂移)。
|
|
3
|
+
// MSLXDFF_QODER_LEGACY_WIRE=1 → 回退旧形状(模板注入历史 + OpenAI messages 原样)。
|
|
3
4
|
import { getEndpoints, normalizeRegion } from "./constants.js";
|
|
4
5
|
import { buildCosyHeaders, pathSigFrom } from "./session.js";
|
|
5
6
|
import { cosyEncode } from "./encode.js";
|
|
6
7
|
import { buildQoderBody, mapModel } from "./payload.js";
|
|
7
8
|
|
|
8
|
-
export function buildUpstreamRequest({ sess, region, model, messages, tools, maxTokens, isReasoning }) {
|
|
9
|
+
export function buildUpstreamRequest({ sess, region, model, messages, tools, maxTokens, isReasoning, meta, effort }) {
|
|
9
10
|
const upstreamModel = mapModel(model);
|
|
10
11
|
const { body, mcSource } = buildQoderBody({
|
|
11
12
|
template: undefined,
|
|
13
|
+
userId: sess?.identity?.uid,
|
|
12
14
|
userType: sess?.identity?.userType,
|
|
13
15
|
model: upstreamModel,
|
|
14
16
|
messages,
|
|
15
17
|
tools,
|
|
16
18
|
maxTokens,
|
|
17
19
|
isReasoning,
|
|
20
|
+
meta,
|
|
21
|
+
effort,
|
|
22
|
+
preserveTemplateHistory: process.env.MSLXDFF_QODER_LEGACY_WIRE === "1",
|
|
18
23
|
});
|
|
19
24
|
const ep = getEndpoints(normalizeRegion(region));
|
|
20
25
|
const url = ep.chatStreamURL;
|
|
@@ -23,5 +28,5 @@ export function buildUpstreamRequest({ sess, region, model, messages, tools, max
|
|
|
23
28
|
headers["x-model-key"] = upstreamModel;
|
|
24
29
|
headers["x-model-source"] = mcSource;
|
|
25
30
|
headers["accept"] = "text/event-stream";
|
|
26
|
-
return { url, headers, bodyStr, upstreamModel, mcSource };
|
|
31
|
+
return { url, headers, bodyStr, upstreamModel, mcSource, requestSetId: body.request_set_id, sessionId: body.session_id, modelKey: upstreamModel };
|
|
27
32
|
}
|
|
@@ -6,7 +6,12 @@ import { cosyEncode } from "./encode.js";
|
|
|
6
6
|
import { newUUID, unixSec } from "./fingerprint.js";
|
|
7
7
|
|
|
8
8
|
export const APP_CODE = "cosy";
|
|
9
|
-
|
|
9
|
+
// 身份版本(对齐外部参考 dsh-qoder-connect wire/cosy.ts 的 CLI 口径 1.1.47;env 可覆盖以便上游升版)
|
|
10
|
+
export const VERSION = process.env.MSLXDFF_QODER_CLI_VERSION || "1.1.47";
|
|
11
|
+
export const CLI_UA = `qoder/${VERSION}`;
|
|
12
|
+
const MACHINE_OS = process.platform === "win32"
|
|
13
|
+
? (process.arch === "arm64" ? "aarch64_windows" : "x86_64_windows")
|
|
14
|
+
: (process.arch === "arm64" ? "aarch64_linux" : "x86_64_linux");
|
|
10
15
|
// base64("war, war never changes")
|
|
11
16
|
const SECRET_B64 = "d2FyLCB3YXIgbmV2ZXIgY2hhbmdlcw==";
|
|
12
17
|
|
|
@@ -106,30 +111,41 @@ export function pathSigFrom(url) {
|
|
|
106
111
|
}
|
|
107
112
|
}
|
|
108
113
|
|
|
109
|
-
//
|
|
110
|
-
|
|
114
|
+
// 构造请求头(身份对齐 qodercli 参考实现:UA=qoder/<ver>、补 bodyhash/sigpath/machineos/x-request-id,
|
|
115
|
+
// 摘掉 IDE 伪装头 cosy-scene/business-*——上游按请求头指纹分级,IDE+Go UA 是排队重灾区)
|
|
116
|
+
// correlation: { requestId, sessionId } —— 队列探活与推理必须携带同一对 id 才能关联。
|
|
117
|
+
export function buildCosyHeaders(sess, pathSig, bodyStr, accept, correlation) {
|
|
111
118
|
const payloadB64 = buildPayloadB64(sess.info);
|
|
112
119
|
const date = String(unixSec());
|
|
113
|
-
const
|
|
120
|
+
const body = bodyStr || "";
|
|
121
|
+
const sig = signRequest(payloadB64, sess.cosyKey, date, body, pathSig);
|
|
114
122
|
const bearer = composeBearer(payloadB64, sig);
|
|
123
|
+
const bodyHash = createHash("md5").update(body).digest("hex");
|
|
115
124
|
return {
|
|
116
|
-
"cosy-data-policy": "
|
|
125
|
+
"cosy-data-policy": "disagree",
|
|
117
126
|
"content-type": "application/json",
|
|
118
127
|
"cosy-machinetype": sess.machineType,
|
|
128
|
+
"cosy-machinetoken": sess.machineToken,
|
|
129
|
+
"cosy-machineos": MACHINE_OS,
|
|
119
130
|
"cosy-clienttype": "5",
|
|
131
|
+
"cosy-clientip": "127.0.0.1",
|
|
132
|
+
"cosy-bodyhash": bodyHash,
|
|
133
|
+
"cosy-bodylength": String(Buffer.byteLength(body)),
|
|
134
|
+
"cosy-sigpath": pathSig,
|
|
135
|
+
"cosy-organization-id": sess.identity?.organizationId || "",
|
|
136
|
+
"cosy-organization-tags": "",
|
|
120
137
|
"cosy-date": date,
|
|
121
138
|
"cosy-user": sess.identity.uid,
|
|
122
139
|
"cosy-key": sess.cosyKey,
|
|
123
140
|
"cache-control": "no-cache",
|
|
124
141
|
"accept": accept || "application/json",
|
|
142
|
+
"accept-encoding": "identity",
|
|
125
143
|
"authorization": bearer,
|
|
126
144
|
"cosy-version": VERSION,
|
|
127
145
|
"cosy-machineid": sess.machineId,
|
|
128
|
-
"cosy-machinetoken": sess.machineToken,
|
|
129
146
|
"login-version": "v2",
|
|
130
|
-
"user-agent":
|
|
131
|
-
"
|
|
132
|
-
"
|
|
133
|
-
"cosy-business-type": "agent",
|
|
147
|
+
"user-agent": CLI_UA,
|
|
148
|
+
"x-request-id": correlation?.requestId || newUUID(),
|
|
149
|
+
...(correlation?.sessionId ? { "x-session-id": String(correlation.sessionId) } : {}),
|
|
134
150
|
};
|
|
135
151
|
}
|