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
|
@@ -13,6 +13,10 @@ export function extractDelta(dataLine) {
|
|
|
13
13
|
}
|
|
14
14
|
if (wrapper.statusCodeValue !== undefined && Number(wrapper.statusCodeValue) !== 200) {
|
|
15
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 } };
|
|
16
20
|
// 额度耗尽可能裹在非 200 信封里,且实测是**多层嵌套**(外层 statusCodeValue:403 →
|
|
17
21
|
// code:10605 → 内层 code:"110" / "Billing daily count exceeded")。只看一层会把额度错
|
|
18
22
|
// 当普通 403;显式扫各层信号才能可靠命中(排队 10605 无 110 信号,天然不误判)。
|
|
@@ -22,7 +26,11 @@ export function extractDelta(dataLine) {
|
|
|
22
26
|
// 不新增 kind:errorStatus 与流内 error 的 type 字段一律不变(对外契约零改动),
|
|
23
27
|
// 门面据 x-mslxdff-qoder-queued 走队列档冷却 + 同请求换号。
|
|
24
28
|
const queued = findQueueSignal(wrapper);
|
|
25
|
-
|
|
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 } };
|
|
26
34
|
}
|
|
27
35
|
const inner = wrapper.body;
|
|
28
36
|
if (!inner || typeof inner !== "string") {
|
|
@@ -34,6 +42,9 @@ export function extractDelta(dataLine) {
|
|
|
34
42
|
ddbg(`[inner-parse-fail] ${e.message} body=${inner.slice(0, 300)}`);
|
|
35
43
|
return {};
|
|
36
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 } };
|
|
37
48
|
let usageIn = 0, usageOut = 0;
|
|
38
49
|
if (j.usage && typeof j.usage === "object") {
|
|
39
50
|
usageIn = Math.trunc(Number(j.usage.prompt_tokens) || 0);
|
|
@@ -56,10 +67,13 @@ export function extractDelta(dataLine) {
|
|
|
56
67
|
}
|
|
57
68
|
if (typeof j.code === "string" && j.code && j.code !== "0") {
|
|
58
69
|
const msg = String(j.message || "");
|
|
59
|
-
//
|
|
60
|
-
|
|
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 专属措辞):按配额错单列,门面据此换号(长冷却)。
|
|
61
73
|
if (isQuotaError(j.code, msg)) return { err: { kind: "quota", status: 429, code: j.code, detail: msg || `code=${j.code}` } };
|
|
62
|
-
|
|
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 } };
|
|
63
77
|
}
|
|
64
78
|
if (finishReason) return { finishReason, usageIn, usageOut };
|
|
65
79
|
if (usageIn > 0 || usageOut > 0) return { usageIn, usageOut };
|
|
@@ -69,12 +83,26 @@ export function extractDelta(dataLine) {
|
|
|
69
83
|
return {};
|
|
70
84
|
}
|
|
71
85
|
|
|
72
|
-
/** 额度耗尽识别:
|
|
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"]);
|
|
73
93
|
export function isQuotaError(code, message) {
|
|
74
|
-
if (String(code || "").trim()
|
|
94
|
+
if (QUOTA_CODES.has(String(code || "").trim())) return true;
|
|
75
95
|
return /billing\s*(daily)?\s*(count|limit|quota)?\s*exceed/i.test(String(message || ""));
|
|
76
96
|
}
|
|
77
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
|
+
|
|
78
106
|
// 显式额度信号扫描:信封各层里找 code 110 / Billing 明确措辞。
|
|
79
107
|
// 限深 4 层(实测 3 层足够,留一层余量),字符串层自动尝试 JSON.parse 后继续下钻。
|
|
80
108
|
// 排队 10605(isQueued/serviceAvailable)无 110 信号 → 返回 null,天然不误判为额度。
|
|
@@ -104,21 +132,36 @@ export function findQuotaSignal(value, depth = 0) {
|
|
|
104
132
|
|
|
105
133
|
/** 排队识别(与额度共用 10605 容器 → 判序必须先额度、后排队的纯函数):
|
|
106
134
|
* 命中条件 = 容器码 10605,或内层显式 isQueued:true / serviceAvailable:false。
|
|
107
|
-
* 实测形态:403 信封 → code 10605 → message 里带 isQueued:true、retryAfterSeconds:30。
|
|
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 同法:判决前先剥反斜杠归一,令任意嵌套层度的字面量都还原为可匹配形态。 */
|
|
108
142
|
export function isQueueSignal(code, message) {
|
|
109
143
|
if (String(code || "").trim() === QUEUE_CODE) return true;
|
|
110
|
-
const s = String(message || "");
|
|
144
|
+
const s = String(message || "").replace(/\\/g, "");
|
|
111
145
|
if (!s) return false;
|
|
112
146
|
return /"isQueued"\s*:\s*true/.test(s) || /"serviceAvailable"\s*:\s*false/.test(s);
|
|
113
147
|
}
|
|
114
148
|
|
|
115
|
-
|
|
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 }。 */
|
|
116
159
|
export function findQueueSignal(value, depth = 0) {
|
|
117
160
|
if (value == null || depth > MAX_ENVELOPE_DEPTH) return null;
|
|
118
161
|
if (typeof value === "string") {
|
|
119
162
|
const s = value.trim();
|
|
120
163
|
if (!s) return null;
|
|
121
|
-
if (isQueueSignal("", s)) return { code: QUEUE_CODE };
|
|
164
|
+
if (isQueueSignal("", s)) return { code: QUEUE_CODE, noCapacity: isNoCapacitySignal(s) };
|
|
122
165
|
try {
|
|
123
166
|
return findQueueSignal(JSON.parse(s), depth + 1);
|
|
124
167
|
} catch {
|
|
@@ -126,7 +169,9 @@ export function findQueueSignal(value, depth = 0) {
|
|
|
126
169
|
}
|
|
127
170
|
}
|
|
128
171
|
if (typeof value !== "object") return null;
|
|
129
|
-
if (isQueueSignal(value.code, value.message))
|
|
172
|
+
if (isQueueSignal(value.code, value.message)) {
|
|
173
|
+
return { code: String(value.code || QUEUE_CODE), noCapacity: isNoCapacitySignal(String(value.message || "")) };
|
|
174
|
+
}
|
|
130
175
|
for (const v of Object.values(value)) {
|
|
131
176
|
const hit = findQueueSignal(v, depth + 1);
|
|
132
177
|
if (hit) return hit;
|
|
@@ -134,10 +179,53 @@ export function findQueueSignal(value, depth = 0) {
|
|
|
134
179
|
return null;
|
|
135
180
|
}
|
|
136
181
|
|
|
137
|
-
//
|
|
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 直出)
|
|
138
225
|
export function errorStatus(err) {
|
|
139
226
|
if (!err) return 500;
|
|
140
227
|
if (err.kind === "content_policy") return 400;
|
|
228
|
+
if (err.kind === "request") return 400; // 换号无用:如实 400 给客户端,不冷却不换号
|
|
141
229
|
if (err.kind === "auth") return [401, 403].includes(err.status) ? err.status : 401;
|
|
142
230
|
if (err.kind === "upstream") return [401, 403, 429].includes(err.status) ? err.status : 502;
|
|
143
231
|
if (err.kind === "quota") return 429; // 额度错按限流语义出(门面据此换号/冷却)
|
|
@@ -15,7 +15,7 @@ export function createStickyPicker({ pick, isCooling = () => false, now = Date.n
|
|
|
15
15
|
|
|
16
16
|
// onDecision(可选):把"这次为什么选它"回报给调用方 —— 决定必须可观测。
|
|
17
17
|
// sticky=同请求复用 / new=新请求轮转选中 / switch=上次的号在冷却(或 TTL 过期)故换 / rr=无 scope 退回轮转
|
|
18
|
-
|
|
18
|
+
function pickSticky(scope, onDecision) {
|
|
19
19
|
if (!scope) {
|
|
20
20
|
onDecision?.("rr");
|
|
21
21
|
return pick();
|
|
@@ -34,5 +34,8 @@ export function createStickyPicker({ pick, isCooling = () => false, now = Date.n
|
|
|
34
34
|
if (seen.size > maxEntries) evict();
|
|
35
35
|
}
|
|
36
36
|
return key;
|
|
37
|
-
}
|
|
37
|
+
}
|
|
38
|
+
// forget:丢弃 scope 的粘号记录(令牌刷新换了 key 后必须丢弃,否则同请求重试还粘旧 key)
|
|
39
|
+
pickSticky.forget = (scope) => { seen.delete(scope); };
|
|
40
|
+
return pickSticky;
|
|
38
41
|
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// qoder 线格式消息转换层:OpenAI/Anthropic messages → Qoder 原生 wire messages。
|
|
2
|
+
// 从 payload.js 拆出(体积门 + 职责边界:这里只管"消息形状/配对/缓存标记/会话指纹",不管请求体装配)。
|
|
3
|
+
// 关键事实(2026-10-22 取证,对齐官方 qodercli + dsh-qoder-connect + magpie qoder 插件):
|
|
4
|
+
// - messages 必须发 Qoder 原生形状(user 带 contents[]、assistant 回传 reasoning_content、tool 带 tool_call_id);
|
|
5
|
+
// - 注入官方 Qoder agent 人格(缓存稳定种子);末个可缓存块打 cache_control:ephemeral(Anthropic 系只缓存标记段);
|
|
6
|
+
// - session_id 按对话稳定(同对话跨请求命中上游 prompt 缓存);
|
|
7
|
+
// - tool 配对修复:上游对「孤儿 tool result」整体拒收,须补派生 id / 防抢占认领 / 降级不丢内容。
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
|
|
10
|
+
export function blankResponseMeta() {
|
|
11
|
+
return {
|
|
12
|
+
id: "",
|
|
13
|
+
usage: {
|
|
14
|
+
prompt_tokens: 0, completion_tokens: 0, total_tokens: 0,
|
|
15
|
+
completion_tokens_details: { reasoning_tokens: 0 },
|
|
16
|
+
prompt_tokens_details: { cached_tokens: 0 },
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// OpenAI user 消息 → Qoder contents 形状
|
|
22
|
+
export function buildUserMessage(text) {
|
|
23
|
+
return {
|
|
24
|
+
role: "user", content: "",
|
|
25
|
+
contents: [{ type: "text", text: String(text || "") }],
|
|
26
|
+
response_meta: blankResponseMeta(),
|
|
27
|
+
reasoning_content_signature: "",
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildStructuredMessage(role, text) {
|
|
32
|
+
return { role, content: String(text || ""), response_meta: blankResponseMeta(), reasoning_content_signature: "" };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function normalizeContent(content) {
|
|
36
|
+
if (typeof content === "string") return content;
|
|
37
|
+
if (content == null) return "";
|
|
38
|
+
if (Array.isArray(content)) {
|
|
39
|
+
let out = "";
|
|
40
|
+
for (const b of content) {
|
|
41
|
+
const t = typeof b === "object" && b ? b.text : b;
|
|
42
|
+
if (typeof t === "string") out += (out ? "\n\n" : "") + t;
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
return JSON.stringify(content);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 单段 image → Qoder image_url part;拿不到可用 url 返回 null
|
|
50
|
+
function imagePartToUrl(b) {
|
|
51
|
+
// OpenAI 形状 {type:'image_url', image_url:{url}}
|
|
52
|
+
const direct = typeof b.image_url === "string" ? b.image_url : b.image_url?.url;
|
|
53
|
+
if (typeof direct === "string" && direct) return { type: "image_url", image_url: { url: direct } };
|
|
54
|
+
// Anthropic 形状 {type:'image', source:{type:'base64'|'url', media_type, data|url}}
|
|
55
|
+
const src = b.source;
|
|
56
|
+
if (src && typeof src === "object") {
|
|
57
|
+
if (src.type === "url" && typeof src.url === "string" && src.url)
|
|
58
|
+
return { type: "image_url", image_url: { url: src.url } };
|
|
59
|
+
if (src.type === "base64" && src.data && src.media_type)
|
|
60
|
+
return { type: "image_url", image_url: { url: `data:${src.media_type};base64,${src.data}` } };
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 官方会话人格(对齐 qoder-agent-sdk:magpie 插件同款):人格行前置、客户端 system 拼后。
|
|
66
|
+
// 固定前缀让上游 prompt 缓存跨轮命中,也让模型身份口径稳定(不再自称别名)。
|
|
67
|
+
const QODER_SYS = "You are a Qoder agent. Use the instructions below and the tools available to you to assist the user.";
|
|
68
|
+
export function withQoderPersona(systemText) {
|
|
69
|
+
const s = String(systemText || "").trim();
|
|
70
|
+
return s ? `${QODER_SYS}\n\n${s}` : QODER_SYS;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// tool 配对修复(对齐 magpie #277/#290/#359,实测上游行为):
|
|
74
|
+
// - 上游对「tool 行没有应答它前面 assistant 的某个 call」的请求整体拒绝
|
|
75
|
+
// ("Messages with role 'tool' must be a response to a preceding message with 'tool_calls'"),
|
|
76
|
+
// 客户端丢 id / 上下文压缩截断都会造成孤儿 result → 整个 turn 被枪毙(表现为莫名的 400/空 turn)。
|
|
77
|
+
// - 修法:call 缺 id 按内容派生稳定 id(同 turn 内跨请求一致,不破坏上游缓存);
|
|
78
|
+
// 孤儿 result 认领「其后没有任何 result 点名的那个未应答 call」(防孤儿抢占合法点名),认领不到降级 user 文本。
|
|
79
|
+
function callIdFor(at, k, c) {
|
|
80
|
+
const said = typeof c.function?.arguments === "string" ? c.function.arguments : JSON.stringify(c.function?.arguments ?? {});
|
|
81
|
+
return "call_" + createHash("sha256").update(`${at}\0${k}\0${c.function?.name ?? ""}\0${said}`).digest("hex").slice(0, 32);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 从 from 起收集其后所有 result 行点名的 id(user 的 tool_result 数组形态与 tool 行都算),
|
|
85
|
+
// 直到下一个 assistant/user 文本行为止——这是"合法点名预约表",孤儿认领时须避开。
|
|
86
|
+
function namedAhead(incoming, from) {
|
|
87
|
+
const names = new Set();
|
|
88
|
+
for (let j = from; j < incoming.length; j++) {
|
|
89
|
+
const m = incoming[j];
|
|
90
|
+
if (!m) continue;
|
|
91
|
+
if (m.role === "tool") { if (m.tool_call_id) names.add(String(m.tool_call_id)); continue; }
|
|
92
|
+
if (m.role === "user" && Array.isArray(m.content) && m.content[0]?.type === "tool_result") {
|
|
93
|
+
for (const b of m.content) if (b?.type === "tool_result" && b.tool_use_id) names.add(String(b.tool_use_id));
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
break; // 碰到别的角色(assistant 新 turn / user 真话)即停
|
|
97
|
+
}
|
|
98
|
+
return names;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// OpenAI messages → Qoder 原生 wire messages(官方形状:user=contents[];assistant=content+tool_calls+reasoning_content)
|
|
102
|
+
export function toQoderWireMessages(incoming) {
|
|
103
|
+
const msgs = incoming || [];
|
|
104
|
+
const out = [];
|
|
105
|
+
let pendingCallIds = []; // 最近一个 assistant 轮里尚未被 result 应答的 call ids(按发出顺序)
|
|
106
|
+
let systemText = "";
|
|
107
|
+
const claimOrDemote = (id, at) => {
|
|
108
|
+
const sid = String(id || "");
|
|
109
|
+
if (sid && pendingCallIds.includes(sid)) {
|
|
110
|
+
pendingCallIds = pendingCallIds.filter((p) => p !== sid);
|
|
111
|
+
return { ok: true, id: sid };
|
|
112
|
+
}
|
|
113
|
+
// 客户端丢了 id / 点名了对不上的 id:认领「后面没人点名」的第一个未应答 call
|
|
114
|
+
const reserved = namedAhead(msgs, at);
|
|
115
|
+
const freeAt = pendingCallIds.findIndex((p) => !reserved.has(p));
|
|
116
|
+
if (freeAt >= 0) return { ok: true, id: pendingCallIds.splice(freeAt, 1)[0] };
|
|
117
|
+
return { ok: false };
|
|
118
|
+
};
|
|
119
|
+
for (let i = 0; i < msgs.length; i++) {
|
|
120
|
+
const m = msgs[i];
|
|
121
|
+
if (!m || typeof m !== "object") continue;
|
|
122
|
+
const text = normalizeContent(m.content);
|
|
123
|
+
if (m.role === "system") {
|
|
124
|
+
if (text) systemText = systemText ? systemText + "\n\n" + text : text;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (m.role === "user") {
|
|
128
|
+
pendingCallIds = [];
|
|
129
|
+
// Anthropic tool_result 数组形态 → tool 行(翻译层产物)
|
|
130
|
+
if (Array.isArray(m.content) && m.content[0]?.type === "tool_result") {
|
|
131
|
+
for (const b of m.content) {
|
|
132
|
+
if (b?.type !== "tool_result") continue;
|
|
133
|
+
const rt = normalizeContent(b.content);
|
|
134
|
+
const claimed = claimOrDemote(b.tool_use_id || "", i);
|
|
135
|
+
if (claimed.ok) out.push({ role: "tool", tool_call_id: claimed.id, content: rt });
|
|
136
|
+
else out.push(buildUserMessage(rt)); // 无路可认领 → 降级 user 文本,内容不丢
|
|
137
|
+
}
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (Array.isArray(m.content)) {
|
|
141
|
+
const parts = [];
|
|
142
|
+
for (const b of m.content) {
|
|
143
|
+
if (!b || typeof b !== "object") continue;
|
|
144
|
+
if (b.type === "text" && typeof b.text === "string" && b.text) { parts.push({ type: "text", text: b.text }); continue; }
|
|
145
|
+
if (b.type === "image" || b.type === "image_url") {
|
|
146
|
+
const p = imagePartToUrl(b);
|
|
147
|
+
if (p) { parts.push(p); continue; }
|
|
148
|
+
}
|
|
149
|
+
const t = typeof b.text === "string" ? b.text : null;
|
|
150
|
+
if (t) parts.push({ type: "text", text: t });
|
|
151
|
+
}
|
|
152
|
+
if (!parts.length && text) parts.push({ type: "text", text });
|
|
153
|
+
out.push({
|
|
154
|
+
role: "user", content: "",
|
|
155
|
+
contents: parts.length ? parts : [{ type: "text", text: "" }],
|
|
156
|
+
response_meta: blankResponseMeta(),
|
|
157
|
+
reasoning_content_signature: "",
|
|
158
|
+
});
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
out.push(buildUserMessage(text));
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (m.role === "assistant") {
|
|
165
|
+
const hasToolCalls = Array.isArray(m.tool_calls) && m.tool_calls.length > 0;
|
|
166
|
+
if (!text && !hasToolCalls && !m.reasoning_content) continue; // 空气泡不发
|
|
167
|
+
pendingCallIds = [];
|
|
168
|
+
const msg = buildStructuredMessage("assistant", text || " ");
|
|
169
|
+
if (hasToolCalls) {
|
|
170
|
+
msg.tool_calls = m.tool_calls.map((c, k) => (c?.id ? c : { ...c, id: callIdFor(i, k, c || {}) }));
|
|
171
|
+
pendingCallIds = msg.tool_calls.map((c) => c.id);
|
|
172
|
+
}
|
|
173
|
+
if (m.reasoning_content) msg.reasoning_content = String(m.reasoning_content);
|
|
174
|
+
out.push(msg);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (m.role === "tool") {
|
|
178
|
+
const claimed = claimOrDemote(m.tool_call_id || "", i);
|
|
179
|
+
if (claimed.ok) {
|
|
180
|
+
const msg = buildStructuredMessage("tool", text);
|
|
181
|
+
msg.tool_call_id = claimed.id;
|
|
182
|
+
if (m.name) msg.name = m.name;
|
|
183
|
+
out.push(msg);
|
|
184
|
+
} else {
|
|
185
|
+
out.push(buildUserMessage(text)); // 孤儿 result → user 文本(上游会整体拒绝 tool 行)
|
|
186
|
+
}
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (text) out.push(buildStructuredMessage(m.role || "user", text));
|
|
190
|
+
}
|
|
191
|
+
// system 人格前置一条(客户端无 system 时也给官方人格,对齐上游客户端行为)
|
|
192
|
+
out.unshift(buildStructuredMessage("system", withQoderPersona(systemText)));
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 会话指纹(prompt 缓存亲和的关键,对齐 magpie #290):同一对话(system + 首个 user 相同)
|
|
197
|
+
// 恒定同名 → 上游命中会话缓存,首包显著变快;不同对话各不相同。客户端的 key 原文绝不出门。
|
|
198
|
+
export function conversationSessionId(userId, modelKey, wireMessages) {
|
|
199
|
+
const sys = [];
|
|
200
|
+
let firstUser = "";
|
|
201
|
+
for (const m of wireMessages) {
|
|
202
|
+
if (m?.role === "system" && m.content) sys.push(String(m.content));
|
|
203
|
+
if (m?.role === "user") {
|
|
204
|
+
firstUser = JSON.stringify(m.contents || m.content || "");
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const h = createHash("sha256");
|
|
209
|
+
h.update("qoder-conv");
|
|
210
|
+
h.update("\0");
|
|
211
|
+
h.update(String(userId || ""));
|
|
212
|
+
h.update("\0");
|
|
213
|
+
h.update(String(modelKey || ""));
|
|
214
|
+
h.update("\0");
|
|
215
|
+
h.update(JSON.stringify([sys, firstUser]));
|
|
216
|
+
const hex = h.digest("hex");
|
|
217
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// cache_control 标记(对齐 qoder-agent-sdk 真值,magpie #290):最后一个 user 轮里
|
|
221
|
+
// 最后一个非工具块打 ephemeral;末轮是 tool 结果时不标。Anthropic 系模型只缓存标记段,
|
|
222
|
+
// 不标 = 整段对话每次全价重算(慢且贵)。输入块原样不动,只加标记。
|
|
223
|
+
const UNCACHED_TYPES = new Set(["thinking", "redacted_thinking", "tool_use", "tool_result"]);
|
|
224
|
+
function findLastCacheable(blocks) {
|
|
225
|
+
for (let i = blocks.length - 1; i >= 0; i--) {
|
|
226
|
+
const t = blocks[i]?.type;
|
|
227
|
+
if (t && !UNCACHED_TYPES.has(t)) return i;
|
|
228
|
+
}
|
|
229
|
+
return -1;
|
|
230
|
+
}
|
|
231
|
+
export function cacheMark(wireMessages) {
|
|
232
|
+
const last = wireMessages[wireMessages.length - 1];
|
|
233
|
+
if (!last || last.role !== "user") return wireMessages;
|
|
234
|
+
const blocks = Array.isArray(last.contents) ? last.contents : null;
|
|
235
|
+
if (!blocks || !blocks.length) return wireMessages;
|
|
236
|
+
const idx = findLastCacheable(blocks);
|
|
237
|
+
if (idx < 0) return wireMessages;
|
|
238
|
+
const marked = blocks.slice();
|
|
239
|
+
marked[idx] = { ...marked[idx], cache_control: { type: "ephemeral" } };
|
|
240
|
+
wireMessages[wireMessages.length - 1] = { ...last, contents: marked };
|
|
241
|
+
return wireMessages;
|
|
242
|
+
}
|
|
@@ -29,7 +29,7 @@ const PEEK_MAX_BYTES = 8192;
|
|
|
29
29
|
* 出站请求体:剥 `raccoon/` 前缀 + 注入 `extra_body.thinking`。
|
|
30
30
|
* 思考取值优先级:**请求体声明的档位**(`extra_body.thinking.type` / `reasoning_effort` / `reasoningEffort`
|
|
31
31
|
* / `effort` / `thinking.type`,经 src/thinking-effort.js 归一,关断值最高优先)> 构造/调用方 `effort`
|
|
32
|
-
* > env `MSLXDFF_RACCOON_THINKING` >
|
|
32
|
+
* > env `MSLXDFF_RACCOON_THINKING` > 默认「关」(见 const.js 的 RACCOON_DEFAULT_EFFORT)。
|
|
33
33
|
* **绝不发 `reasoning_effort`**:上游不认该参数(实测传与不传无可测差异),发了只会多一处形态差异。
|
|
34
34
|
* 客户端声明的通用档位字段消费后一律剥除,出站只留本家原生形状 `extra_body.thinking.type`。
|
|
35
35
|
*/
|
|
@@ -147,7 +147,21 @@ export function raccoonUserAgent(env = process.env) {
|
|
|
147
147
|
export const RACCOON_EFFORT_ON = "on";
|
|
148
148
|
export const RACCOON_EFFORT_OFF = "off";
|
|
149
149
|
export const RACCOON_EFFORTS = [RACCOON_EFFORT_ON, RACCOON_EFFORT_OFF];
|
|
150
|
-
|
|
150
|
+
/**
|
|
151
|
+
* 默认**关思考**(2026-10-22 本机实测改判,旧值 on)。
|
|
152
|
+
*
|
|
153
|
+
* 上游开着思考时,reasoning 与正文抢同一份 `max_tokens`,且推理极慢,两件事叠加正好
|
|
154
|
+
* 把这家变成「看起来通、实际用不了」:
|
|
155
|
+
* · sn-deepseek-v4-1-flash:max_tokens=2000 时 2000 个 completion token **全是 reasoning**,
|
|
156
|
+
* 正文 0 字、finish=length(talkfull 15:49:35 实录);
|
|
157
|
+
* · Claude Code 形态的请求(system + tools)TTFT 实测 26~84 秒,网关 300s 流闸门、
|
|
158
|
+
* 客户端首字节耐心全在这段时间里耗着;
|
|
159
|
+
* · sn-sensenova-6-8-flash 更绝:连 `thinking.type=disabled` 都压不住,仍产出 980
|
|
160
|
+
* reasoning token / 1024 上限 → 正文 80 字即被截断。
|
|
161
|
+
* 关掉思考后同一请求 TTFT 降到 0.9~4.5 秒、正文完整,这是**可用性**问题不是偏好问题,
|
|
162
|
+
* 故默认值取 off;要思考的调用方显式传档位(`effort`/请求体 thinking)仍照传,不拦。
|
|
163
|
+
*/
|
|
164
|
+
export const RACCOON_DEFAULT_EFFORT = RACCOON_EFFORT_OFF;
|
|
151
165
|
|
|
152
166
|
/**
|
|
153
167
|
* 思考档位 → 出站 `thinking.type`。
|
|
@@ -158,12 +172,15 @@ export function raccoonThinkingType(effort) {
|
|
|
158
172
|
}
|
|
159
173
|
|
|
160
174
|
/**
|
|
161
|
-
* 思考开关的环境默认:`MSLXDFF_RACCOON_THINKING=
|
|
162
|
-
* 优先级见 `chat.js` 的 `buildRaccoonWireBody`(请求体显式声明 > effort 参数 > 本 env >
|
|
175
|
+
* 思考开关的环境默认:`MSLXDFF_RACCOON_THINKING=on|1|true|yes|enabled` 开,其余(含未设)=关。
|
|
176
|
+
* 优先级见 `chat.js` 的 `buildRaccoonWireBody`(请求体显式声明 > effort 参数 > 本 env > 默认关)。
|
|
177
|
+
* ⚠ 与旧实现相反(旧为「非 off 即开」):默认值改关的原因见 `RACCOON_DEFAULT_EFFORT`。
|
|
163
178
|
*/
|
|
164
179
|
export function raccoonEffortFromEnv(env = process.env) {
|
|
165
180
|
const raw = typeof env?.MSLXDFF_RACCOON_THINKING === "string" ? env.MSLXDFF_RACCOON_THINKING.trim().toLowerCase() : "";
|
|
166
|
-
return raw === "
|
|
181
|
+
return raw === "on" || raw === "1" || raw === "true" || raw === "yes" || raw === "enabled"
|
|
182
|
+
? RACCOON_EFFORT_ON
|
|
183
|
+
: RACCOON_EFFORT_OFF;
|
|
167
184
|
}
|
|
168
185
|
|
|
169
186
|
/** 冷却档位(对齐 qoder/zcode 先例:限流短冷、额度长冷)。 */
|
|
@@ -5,7 +5,7 @@ import { envInt } from "../base.js";
|
|
|
5
5
|
import { createKeyRing } from "../keyring.js";
|
|
6
6
|
import { loadProviderKeys } from "../../state.js";
|
|
7
7
|
import { forwardRaccoonChat } from "./chat.js";
|
|
8
|
-
import { buildRaccoonModelList, listRaccoonModels } from "./models.js";
|
|
8
|
+
import { buildRaccoonModelList, listRaccoonModels, mergeRaccoonCatalogIntoAllowlist } from "./models.js";
|
|
9
9
|
import { applyRaccoonRefresh, listRaccoonAccountDocs, raccoonCredentialFromDoc, readRaccoonAccountDoc, syncRaccoonKeys, writeRaccoonAccountFile } from "./account-store.js";
|
|
10
10
|
import { refreshRaccoonCredential } from "./login.js";
|
|
11
11
|
import { isRaccoonExpiringSoon, raccoonTokenFingerprint } from "./auth.js";
|
|
@@ -178,12 +178,25 @@ export function createRaccoonProvider({
|
|
|
178
178
|
return allCoolingResponse();
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
let catalogMergeTried = false;
|
|
181
182
|
async function listModels() {
|
|
182
183
|
let token = ring.keys[0];
|
|
183
184
|
// 内存号已不在文档里(重登换号)时先对齐,否则目录撞 401 后静默回落内置表,表现为「列表不是最新的」
|
|
184
185
|
if (token && !accountFor(token)) token = (await healKeyFromDocs(token)) || token;
|
|
185
186
|
if (!token) return [];
|
|
186
|
-
const { models } = await listRaccoonModels({ credential: credentialFor(token), fetchImpl });
|
|
187
|
+
const { models, source } = await listRaccoonModels({ credential: credentialFor(token), fetchImpl });
|
|
188
|
+
// 上游目录是这家唯一真相(模型 id 全由上游定义、按账号下发):每进程首次成功拉到就只增不减
|
|
189
|
+
// 并入 allowlist,否则 dispatcher 会拿登录时播种的静态白名单把新上架模型(如 …-1017)挡掉。
|
|
190
|
+
// 只在 source=upstream 时做:回落兜底表时并入的仍是静态 id,没有意义还会白写盘。
|
|
191
|
+
if (source === "upstream" && !catalogMergeTried) {
|
|
192
|
+
catalogMergeTried = true;
|
|
193
|
+
try {
|
|
194
|
+
const { added } = mergeRaccoonCatalogIntoAllowlist(models, file ? { file } : {});
|
|
195
|
+
if (added.length) console.error(`[raccoon] 上游目录新模型并入白名单: ${added.join(", ")}`);
|
|
196
|
+
} catch (e) {
|
|
197
|
+
console.error(`[raccoon] 目录并入白名单失败(列表照常返回): ${String(e?.message || e).slice(0, 160)}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
187
200
|
return buildRaccoonModelList({ id, models });
|
|
188
201
|
}
|
|
189
202
|
|