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,250 +1,250 @@
|
|
|
1
|
-
// qoder provider 门面(原生直连,零桥依赖):
|
|
2
|
-
// 多号 = keys[] 里每条 device_token blob(auths/qoder-<uid>.json 落盘由 login 维护);
|
|
3
|
-
// 选号 → 建会话 → COSY 签名直调上游;恒 local-only 不借 key。
|
|
4
|
-
// Note: 同请求粘号(一次客户端请求内复用同一个号,仅 401/403/429/5xx 冷却才换)与流式坏号冷却(真实状态码经内部头带出)— 见 .agents/notes/implemented/feature/2026-09-27-qoder-per-request-sticky-account.md
|
|
5
|
-
import { loadProviderKeys, loadProviderConfig } from "../../state.js";
|
|
6
|
-
import { listAccountDocs } from "./account-store.js";
|
|
7
|
-
import { defaultStateFile } from "../../state/store.js";
|
|
8
|
-
import { createKeyRing } from "../keyring.js";
|
|
9
|
-
import { envInt } from "../base.js";
|
|
10
|
-
import { accountFromBlob } from "./account-store.js";
|
|
11
|
-
import { normalizeRegion } from "./constants.js";
|
|
12
|
-
import { fingerprintSeed, deriveMachineId, deriveMachineToken, deriveMachineType } from "./fingerprint.js";
|
|
13
|
-
import { newSession } from "./session.js";
|
|
14
|
-
import { createChatService } from "./chat.js";
|
|
15
|
-
import { createStickyPicker } from "./sticky.js";
|
|
16
|
-
import { createModelsService } from "./models.js";
|
|
17
|
-
import { compatFetch } from "../../compat.js";
|
|
18
|
-
|
|
19
|
-
function buildSessionFor(cred) {
|
|
20
|
-
const uid = cred.uid || cred.deviceToken.slice(0, 8);
|
|
21
|
-
const seed = fingerprintSeed(uid, cred.deviceToken);
|
|
22
|
-
const identity = {
|
|
23
|
-
name: cred.name || "", aid: uid, uid, yxUid: "",
|
|
24
|
-
organizationId: cred.organizationId || "", organizationName: cred.organizationName || "",
|
|
25
|
-
userType: cred.userType || "personal_standard",
|
|
26
|
-
securityOauthToken: cred.deviceToken, refreshToken: cred.refreshToken || "",
|
|
27
|
-
};
|
|
28
|
-
return newSession(identity, deriveMachineId(seed), deriveMachineToken(seed), deriveMachineType(seed));
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function createQoderProvider({
|
|
32
|
-
id = "qoder",
|
|
33
|
-
apiKeys,
|
|
34
|
-
file,
|
|
35
|
-
region,
|
|
36
|
-
fetchImpl,
|
|
37
|
-
sharedModelsSvc, // chatWithKeys 等临时门面复用主实例目录快照(评审路1 P1#3:冷缓存否则恒 false)
|
|
38
|
-
cooldownMs = envInt("MSLXDFF_QODER_COOLDOWN_MS", 30_000),
|
|
39
|
-
// 额度耗尽(code 110)按天重置:短冷却等于反复撞死号,故单独配长冷却(默认 1h)
|
|
40
|
-
quotaCooldownMs = envInt("MSLXDFF_QODER_QUOTA_COOLDOWN_MS", 3600_000),
|
|
41
|
-
// 排队(10605/isQueued)单独一档:队列没放行前反复送回就是白烧(实测跨小时不恢复),默认 5min。
|
|
42
|
-
// 上游口播的 retryAfterSeconds=30 只当参考值,不当本档上限——按 30s 回池等于回到旧行为。
|
|
43
|
-
queueCooldownMs = envInt("MSLXDFF_QODER_QUEUE_COOLDOWN_MS", 300_000),
|
|
44
|
-
connectTimeoutMs = Number(process.env.MSLXDFF_QODER_TIMEOUT_MS) || 120_000,
|
|
45
|
-
} = {}) {
|
|
46
|
-
const keys = (() => {
|
|
47
|
-
if (Array.isArray(apiKeys) && apiKeys.length) return [...new Set(apiKeys.map((k) => String(k).trim()).filter(Boolean))];
|
|
48
|
-
try { return loadProviderKeys(id, file ? { file } : {}); } catch { return []; }
|
|
49
|
-
})();
|
|
50
|
-
let authList = [];
|
|
51
|
-
try { authList = loadProviderConfig(id, file ? { file } : {})?.auths || []; } catch {}
|
|
52
|
-
// auths 行的 region 会被 state.normalizeAuths 剥掉:每号真实 region 从 auths/qoder-<uid>.json 读(单一源)
|
|
53
|
-
try {
|
|
54
|
-
const docs = listAccountDocs();
|
|
55
|
-
for (const d of docs) {
|
|
56
|
-
const row = authList.find((a) => String(a?.uid) === String(d.uid));
|
|
57
|
-
if (row) row.region = d.doc?.auth?.region || "global";
|
|
58
|
-
else authList.push({ uid: d.uid, refreshToken: d.doc?.auth?.refreshToken || "", region: d.doc?.auth?.region || "global", name: d.doc?.account?.name || "" });
|
|
59
|
-
}
|
|
60
|
-
} catch {}
|
|
61
|
-
const ring = createKeyRing(keys, { cooldownMs });
|
|
62
|
-
// 额度耗尽号集合(本次进程内):全号在此集合 → 无可用号时回额度提示而非"无账号"401
|
|
63
|
-
const exhaustedByQuota = new Set();
|
|
64
|
-
if (!fetchImpl) fetchImpl = compatFetch;
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
// 选号:同一客户端请求内粘住(重试不换号),只有该号被冷却(401/403/429/5xx)才换下一个;
|
|
69
|
-
// 无 scope(拿不到 reqId,如独立的 models 探活)时退回原 round-robin。
|
|
70
|
-
const pickKey = createStickyPicker({
|
|
71
|
-
pick: () => ring.next() || keys[0] || "",
|
|
72
|
-
isCooling: (k) => ring.isCooling(k),
|
|
73
|
-
ttlMs: envInt("MSLXDFF_QODER_STICKY_MS", 600_000),
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
// 凭据 → 会话(会话含 RSA/AES 临时密钥,进程内可复用;此处按请求轻建,成本低)
|
|
77
|
-
function pickSession(scope) {
|
|
78
|
-
let decision = "new";
|
|
79
|
-
const key = pickKey(scope, (d) => { decision = d; });
|
|
80
|
-
// 全部号都在冷却时 pick() 只能兜底取 keys[0]:这是"被迫选择",必须与正常轮转区分开
|
|
81
|
-
if (ring.available() === 0) decision = "forced";
|
|
82
|
-
const blob = accountFromBlob(key);
|
|
83
|
-
if (!blob?.deviceToken) return null;
|
|
84
|
-
const auth = authList.find((a) => String(a?.refreshToken || "") === String(blob.refreshToken || "")) || authList[0] || {};
|
|
85
|
-
const region = normalizeRegion(region0 || auth.region || "global");
|
|
86
|
-
const sess = buildSessionFor({ ...blob, uid: auth.uid, name: auth.name, region });
|
|
87
|
-
// key/pick 带出:onError(key) 要冷却"刚用过的这个号";pick 让日志能写"这次为什么是它"
|
|
88
|
-
return { sess, region, key, pick: decision };
|
|
89
|
-
}
|
|
90
|
-
const region0 = region; // 构造参数优先
|
|
91
|
-
|
|
92
|
-
const chatSvc = createChatService({
|
|
93
|
-
id,
|
|
94
|
-
fetchImpl,
|
|
95
|
-
// 目录驱动请求(只读 peekModels 缓存,chat 路径恒 0 额外上游调用;preheat 负责灌缓存)
|
|
96
|
-
getModelMeta: (sess2, reg, key) => {
|
|
97
|
-
const list = modelsSvc.peekModels(reg);
|
|
98
|
-
return list ? list.find((m) => m.id === `${id}/${key}` || m.id === key) || null : null;
|
|
99
|
-
},
|
|
100
|
-
timeoutMs: connectTimeoutMs,
|
|
101
|
-
});
|
|
102
|
-
// models 服务按号选区:构造时无固定 region,listModels(sess, region) 动态传
|
|
103
|
-
const modelsSvc = sharedModelsSvc || createModelsService({ id, fetchImpl });
|
|
104
|
-
|
|
105
|
-
// 失败冷却:401/403/429/5xx 冷却当前号(cooldownMs,默认 30s),坏号不再参与轮换。
|
|
106
|
-
// 额度耗尽(quota)走长冷却(quotaCooldownMs,默认 1h)——额度按天重置,短冷却等于反复撞死号。
|
|
107
|
-
// 额度错在 provider 内直接换号重发(不依赖外层空转重试链路:那条链路在非流式路径不触发)。
|
|
108
|
-
// 收口只认「每个号都被上游明确判过额度」;排队/限流等非额度冷却不得谎称没额度
|
|
109
|
-
// Note: 判定收紧(全号才报 quota_exhausted,其余如实返回/报冷却中)— 见 .agents/notes/implemented/bug-fix/2026-09-28-qoder-quota-cooldown-switch.md
|
|
110
|
-
async function chat(body, opts) {
|
|
111
|
-
// scope=reqId:同一次客户端请求的多次上游调用(空转重试)粘同一个号
|
|
112
|
-
const scope = opts?.reqId ? `req:${opts.reqId}` : null;
|
|
113
|
-
const stripped = { ...body };
|
|
114
|
-
if (typeof stripped.model === "string" && stripped.model.startsWith("qoder/")) stripped.model = stripped.model.slice(6);
|
|
115
|
-
const badAuth = (n) => n === 401 || n === 403 || n === 429 || n >= 500;
|
|
116
|
-
const total = ring.keys.length;
|
|
117
|
-
// 换号上限:每次 continue 必然冷却掉一个号,故最多 total 轮;超出即退出(收敛保证)
|
|
118
|
-
let lastRes = null;
|
|
119
|
-
for (let hop = 0; hop <= total; hop++) {
|
|
120
|
-
const picked = pickSession(scope);
|
|
121
|
-
if (!picked) break;
|
|
122
|
-
const pickedKey = picked.key || "";
|
|
123
|
-
// 该号额度已耗尽且仍在冷却 → 不再打上游(打也是同一堵墙)。另有可用号时继续换,不轻易断言全灭。
|
|
124
|
-
if (exhaustedByQuota.has(pickedKey)) {
|
|
125
|
-
if (ring.isCooling(pickedKey)) {
|
|
126
|
-
if (ring.available() > 0) continue;
|
|
127
|
-
break;
|
|
128
|
-
}
|
|
129
|
-
exhaustedByQuota.delete(pickedKey);
|
|
130
|
-
}
|
|
131
|
-
let res;
|
|
132
|
-
try {
|
|
133
|
-
res = await chatSvc.runChat(stripped, picked.sess, picked.region, picked.pick);
|
|
134
|
-
} catch (e) {
|
|
135
|
-
try { ring.onError(pickedKey); } catch {}
|
|
136
|
-
throw e;
|
|
137
|
-
}
|
|
138
|
-
const st = res?.status ?? 0;
|
|
139
|
-
// 流式路径把上游非 200 整形成 200 + 流内 error(对外契约),真实状态码只能从回显头取;
|
|
140
|
-
// 否则坏号(401/403/429/5xx)永不冷却,粘号还会把重试继续粘在这个坏号上。
|
|
141
|
-
const ust = Number(res?.headers?.get?.("x-mslxdff-qoder-upstream-status")) || 0;
|
|
142
|
-
const isQuota = res?.headers?.get?.("x-mslxdff-qoder-quota") === "1";
|
|
143
|
-
const isQueued = res?.headers?.get?.("x-mslxdff-qoder-queued") === "1";
|
|
144
|
-
const bad = badAuth(st) ? st : (badAuth(ust) ? ust : 0);
|
|
145
|
-
// 冷却三档:额度(按天重置,最长)> 排队(队列没放行前别再送回,默认 5min)> 其它(普通短冷却)。
|
|
146
|
-
// 排队单独成档的依据:现网 31h 窗口 global 区 176 发被判决 129 发且跨小时不复现恢复,
|
|
147
|
-
// 30s 短冷却等于每 30s 白烧一发;但它不是「今天没了」,到点必须自动回池,故不得并入额度档。
|
|
148
|
-
if (bad) {
|
|
149
|
-
const coolMs = isQuota ? quotaCooldownMs : (isQueued ? queueCooldownMs : cooldownMs);
|
|
150
|
-
try { ring.onError(pickedKey, coolMs); } catch {}
|
|
151
|
-
if (isQuota) exhaustedByQuota.add(pickedKey);
|
|
152
|
-
// 冷却是个决定:钉在响应上,管线写进模型日志(cooled=<status>)
|
|
153
|
-
try { res.headers?.set?.("x-mslxdff-qoder-cooldown", String(bad)); } catch {}
|
|
154
|
-
}
|
|
155
|
-
// 额度耗尽 / 排队:本号这一发已废,只要还有别的号就当场换号重发,别把空流或排队判决递给客户端。
|
|
156
|
-
// (跨请求那侧不用改:长冷却让 sticky 下次因 isCooling 自动 switch。)
|
|
157
|
-
if (isQuota || isQueued) {
|
|
158
|
-
lastRes = res;
|
|
159
|
-
if (ring.available() > 0) continue;
|
|
160
|
-
break;
|
|
161
|
-
}
|
|
162
|
-
return res;
|
|
163
|
-
}
|
|
164
|
-
// 收口按「真实全景」分档,不得把「非额度冷却」误报成没额度:
|
|
165
|
-
// 仅当每一个号都被上游明确判过额度,才是真的全号额度耗尽。
|
|
166
|
-
if (total > 0 && ring.keys.every((k) => exhaustedByQuota.has(k))) return quotaExhaustedResponse();
|
|
167
|
-
if (lastRes) return lastRes; // 有上游响应 → 如实透出(含上游真错)
|
|
168
|
-
if (!ring.available()) return allCoolingResponse(); // 全在冷却但非额度 → 冷却中提示
|
|
169
|
-
return new Response(JSON.stringify({ error: { message: "qoder: 无可用账号 — 先跑 mslxdff -provider qoder login", type: "auth_error" } }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// 额度耗尽提示(每一个号都被上游明确判过额度):继续等只会更糟,让调用方立刻知道要换号/等明天
|
|
173
|
-
function quotaExhaustedResponse() {
|
|
174
|
-
return new Response(JSON.stringify({
|
|
175
|
-
error: { message: "qoder: 所有账号额度已用完(Billing daily count exceeded),请更换账号或明日再试", type: "quota_exhausted" },
|
|
176
|
-
}), { status: 429, headers: { "Content-Type": "application/json", "x-mslxdff-qoder-quota-exhausted": "1" } });
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// 冷却中(非额度原因):如实说"暂不可用",不得声称额度耗尽
|
|
180
|
-
function allCoolingResponse() {
|
|
181
|
-
return new Response(JSON.stringify({
|
|
182
|
-
error: { message: "qoder: 账号暂不可用(上游限流/排队冷却中),请稍后重试", type: "all_cooling" },
|
|
183
|
-
}), { status: 429, headers: { "Content-Type": "application/json", "x-mslxdff-qoder-all-cooling": "1" } });
|
|
184
|
-
}
|
|
185
|
-
// 全号聚合:双号分属 cn/global 两区,模型表各不同(cn 14 个/global 15 个);
|
|
186
|
-
// 只取单号会漏另一区(如轮询到 global 就看不到 cn 独有的 q37fmodel/gm51model)。
|
|
187
|
-
// 按 id 并集去重,只返回 enable=true 的可调用模型(过滤已下沉到 models.js)。
|
|
188
|
-
function eachCred() {
|
|
189
|
-
const out = [];
|
|
190
|
-
const seen = new Set();
|
|
191
|
-
const push = (blob, auth) => {
|
|
192
|
-
if (!blob?.deviceToken || seen.has(blob.deviceToken)) return;
|
|
193
|
-
seen.add(blob.deviceToken);
|
|
194
|
-
out.push({ blob, auth: auth || {} });
|
|
195
|
-
};
|
|
196
|
-
for (const k of keys) {
|
|
197
|
-
const blob = accountFromBlob(k);
|
|
198
|
-
if (!blob?.deviceToken) continue;
|
|
199
|
-
const auth = authList.find((a) => String(a?.refreshToken || "") === String(blob.refreshToken || "")) || authList[0] || {};
|
|
200
|
-
push(blob, auth);
|
|
201
|
-
}
|
|
202
|
-
if (!out.length) {
|
|
203
|
-
// keys 为空但 auth 目录有号(如 state 被外部改写):从落盘 doc 合成凭据,目录不断即可出列表
|
|
204
|
-
try {
|
|
205
|
-
for (const { uid, doc } of listAccountDocs()) {
|
|
206
|
-
const a = doc?.auth || {};
|
|
207
|
-
if (!a.deviceToken) continue;
|
|
208
|
-
push({ deviceToken: a.deviceToken, refreshToken: a.refreshToken || "" },
|
|
209
|
-
{ uid, name: doc?.account?.name || "", region: a.region || "global", refreshToken: a.refreshToken || "" });
|
|
210
|
-
}
|
|
211
|
-
} catch {}
|
|
212
|
-
}
|
|
213
|
-
return out;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
async function listModels() {
|
|
217
|
-
const creds = eachCred();
|
|
218
|
-
if (!creds.length) return [];
|
|
219
|
-
const seen = new Set();
|
|
220
|
-
const out = [];
|
|
221
|
-
for (const { blob, auth } of creds) {
|
|
222
|
-
const region = normalizeRegion(region0 || auth.region || "global");
|
|
223
|
-
const sess = buildSessionFor({ ...blob, uid: auth.uid, name: auth.name, region });
|
|
224
|
-
try {
|
|
225
|
-
const list = await modelsSvc.listModels(sess, region);
|
|
226
|
-
for (const m of list) {
|
|
227
|
-
if (!m?.id || seen.has(m.id)) continue;
|
|
228
|
-
seen.add(m.id);
|
|
229
|
-
out.push(m);
|
|
230
|
-
}
|
|
231
|
-
} catch {}
|
|
232
|
-
}
|
|
233
|
-
return out;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
async function preheat() {
|
|
237
|
-
try {
|
|
238
|
-
const list = await listModels();
|
|
239
|
-
return list.length ? { ok: true } : { ok: false, error: "no account" };
|
|
240
|
-
} catch (e) { return { ok: false, error: String(e?.message || e).slice(0, 120) }; }
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
async function close() {}
|
|
244
|
-
async function chatWithKeys(body, keysOverride, opts) {
|
|
245
|
-
const tmp = createQoderProvider({ id, apiKeys: keysOverride, file, fetchImpl, cooldownMs, queueCooldownMs, connectTimeoutMs, sharedModelsSvc: modelsSvc });
|
|
246
|
-
return tmp.chat(body, opts);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
return { id, chat, chatWithKeys, listModels, preheat, close, keyRing: ring, baseUrl: "qoder://native", region: normalizeRegion(region || authList[0]?.region) };
|
|
1
|
+
// qoder provider 门面(原生直连,零桥依赖):
|
|
2
|
+
// 多号 = keys[] 里每条 device_token blob(auths/qoder-<uid>.json 落盘由 login 维护);
|
|
3
|
+
// 选号 → 建会话 → COSY 签名直调上游;恒 local-only 不借 key。
|
|
4
|
+
// Note: 同请求粘号(一次客户端请求内复用同一个号,仅 401/403/429/5xx 冷却才换)与流式坏号冷却(真实状态码经内部头带出)— 见 .agents/notes/implemented/feature/2026-09-27-qoder-per-request-sticky-account.md
|
|
5
|
+
import { loadProviderKeys, loadProviderConfig } from "../../state.js";
|
|
6
|
+
import { listAccountDocs } from "./account-store.js";
|
|
7
|
+
import { defaultStateFile } from "../../state/store.js";
|
|
8
|
+
import { createKeyRing } from "../keyring.js";
|
|
9
|
+
import { envInt } from "../base.js";
|
|
10
|
+
import { accountFromBlob } from "./account-store.js";
|
|
11
|
+
import { normalizeRegion } from "./constants.js";
|
|
12
|
+
import { fingerprintSeed, deriveMachineId, deriveMachineToken, deriveMachineType } from "./fingerprint.js";
|
|
13
|
+
import { newSession } from "./session.js";
|
|
14
|
+
import { createChatService } from "./chat.js";
|
|
15
|
+
import { createStickyPicker } from "./sticky.js";
|
|
16
|
+
import { createModelsService } from "./models.js";
|
|
17
|
+
import { compatFetch } from "../../compat.js";
|
|
18
|
+
|
|
19
|
+
function buildSessionFor(cred) {
|
|
20
|
+
const uid = cred.uid || cred.deviceToken.slice(0, 8);
|
|
21
|
+
const seed = fingerprintSeed(uid, cred.deviceToken);
|
|
22
|
+
const identity = {
|
|
23
|
+
name: cred.name || "", aid: uid, uid, yxUid: "",
|
|
24
|
+
organizationId: cred.organizationId || "", organizationName: cred.organizationName || "",
|
|
25
|
+
userType: cred.userType || "personal_standard",
|
|
26
|
+
securityOauthToken: cred.deviceToken, refreshToken: cred.refreshToken || "",
|
|
27
|
+
};
|
|
28
|
+
return newSession(identity, deriveMachineId(seed), deriveMachineToken(seed), deriveMachineType(seed));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createQoderProvider({
|
|
32
|
+
id = "qoder",
|
|
33
|
+
apiKeys,
|
|
34
|
+
file,
|
|
35
|
+
region,
|
|
36
|
+
fetchImpl,
|
|
37
|
+
sharedModelsSvc, // chatWithKeys 等临时门面复用主实例目录快照(评审路1 P1#3:冷缓存否则恒 false)
|
|
38
|
+
cooldownMs = envInt("MSLXDFF_QODER_COOLDOWN_MS", 30_000),
|
|
39
|
+
// 额度耗尽(code 110)按天重置:短冷却等于反复撞死号,故单独配长冷却(默认 1h)
|
|
40
|
+
quotaCooldownMs = envInt("MSLXDFF_QODER_QUOTA_COOLDOWN_MS", 3600_000),
|
|
41
|
+
// 排队(10605/isQueued)单独一档:队列没放行前反复送回就是白烧(实测跨小时不恢复),默认 5min。
|
|
42
|
+
// 上游口播的 retryAfterSeconds=30 只当参考值,不当本档上限——按 30s 回池等于回到旧行为。
|
|
43
|
+
queueCooldownMs = envInt("MSLXDFF_QODER_QUEUE_COOLDOWN_MS", 300_000),
|
|
44
|
+
connectTimeoutMs = Number(process.env.MSLXDFF_QODER_TIMEOUT_MS) || 120_000,
|
|
45
|
+
} = {}) {
|
|
46
|
+
const keys = (() => {
|
|
47
|
+
if (Array.isArray(apiKeys) && apiKeys.length) return [...new Set(apiKeys.map((k) => String(k).trim()).filter(Boolean))];
|
|
48
|
+
try { return loadProviderKeys(id, file ? { file } : {}); } catch { return []; }
|
|
49
|
+
})();
|
|
50
|
+
let authList = [];
|
|
51
|
+
try { authList = loadProviderConfig(id, file ? { file } : {})?.auths || []; } catch {}
|
|
52
|
+
// auths 行的 region 会被 state.normalizeAuths 剥掉:每号真实 region 从 auths/qoder-<uid>.json 读(单一源)
|
|
53
|
+
try {
|
|
54
|
+
const docs = listAccountDocs();
|
|
55
|
+
for (const d of docs) {
|
|
56
|
+
const row = authList.find((a) => String(a?.uid) === String(d.uid));
|
|
57
|
+
if (row) row.region = d.doc?.auth?.region || "global";
|
|
58
|
+
else authList.push({ uid: d.uid, refreshToken: d.doc?.auth?.refreshToken || "", region: d.doc?.auth?.region || "global", name: d.doc?.account?.name || "" });
|
|
59
|
+
}
|
|
60
|
+
} catch {}
|
|
61
|
+
const ring = createKeyRing(keys, { cooldownMs });
|
|
62
|
+
// 额度耗尽号集合(本次进程内):全号在此集合 → 无可用号时回额度提示而非"无账号"401
|
|
63
|
+
const exhaustedByQuota = new Set();
|
|
64
|
+
if (!fetchImpl) fetchImpl = compatFetch;
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
// 选号:同一客户端请求内粘住(重试不换号),只有该号被冷却(401/403/429/5xx)才换下一个;
|
|
69
|
+
// 无 scope(拿不到 reqId,如独立的 models 探活)时退回原 round-robin。
|
|
70
|
+
const pickKey = createStickyPicker({
|
|
71
|
+
pick: () => ring.next() || keys[0] || "",
|
|
72
|
+
isCooling: (k) => ring.isCooling(k),
|
|
73
|
+
ttlMs: envInt("MSLXDFF_QODER_STICKY_MS", 600_000),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// 凭据 → 会话(会话含 RSA/AES 临时密钥,进程内可复用;此处按请求轻建,成本低)
|
|
77
|
+
function pickSession(scope) {
|
|
78
|
+
let decision = "new";
|
|
79
|
+
const key = pickKey(scope, (d) => { decision = d; });
|
|
80
|
+
// 全部号都在冷却时 pick() 只能兜底取 keys[0]:这是"被迫选择",必须与正常轮转区分开
|
|
81
|
+
if (ring.available() === 0) decision = "forced";
|
|
82
|
+
const blob = accountFromBlob(key);
|
|
83
|
+
if (!blob?.deviceToken) return null;
|
|
84
|
+
const auth = authList.find((a) => String(a?.refreshToken || "") === String(blob.refreshToken || "")) || authList[0] || {};
|
|
85
|
+
const region = normalizeRegion(region0 || auth.region || "global");
|
|
86
|
+
const sess = buildSessionFor({ ...blob, uid: auth.uid, name: auth.name, region });
|
|
87
|
+
// key/pick 带出:onError(key) 要冷却"刚用过的这个号";pick 让日志能写"这次为什么是它"
|
|
88
|
+
return { sess, region, key, pick: decision };
|
|
89
|
+
}
|
|
90
|
+
const region0 = region; // 构造参数优先
|
|
91
|
+
|
|
92
|
+
const chatSvc = createChatService({
|
|
93
|
+
id,
|
|
94
|
+
fetchImpl,
|
|
95
|
+
// 目录驱动请求(只读 peekModels 缓存,chat 路径恒 0 额外上游调用;preheat 负责灌缓存)
|
|
96
|
+
getModelMeta: (sess2, reg, key) => {
|
|
97
|
+
const list = modelsSvc.peekModels(reg);
|
|
98
|
+
return list ? list.find((m) => m.id === `${id}/${key}` || m.id === key) || null : null;
|
|
99
|
+
},
|
|
100
|
+
timeoutMs: connectTimeoutMs,
|
|
101
|
+
});
|
|
102
|
+
// models 服务按号选区:构造时无固定 region,listModels(sess, region) 动态传
|
|
103
|
+
const modelsSvc = sharedModelsSvc || createModelsService({ id, fetchImpl });
|
|
104
|
+
|
|
105
|
+
// 失败冷却:401/403/429/5xx 冷却当前号(cooldownMs,默认 30s),坏号不再参与轮换。
|
|
106
|
+
// 额度耗尽(quota)走长冷却(quotaCooldownMs,默认 1h)——额度按天重置,短冷却等于反复撞死号。
|
|
107
|
+
// 额度错在 provider 内直接换号重发(不依赖外层空转重试链路:那条链路在非流式路径不触发)。
|
|
108
|
+
// 收口只认「每个号都被上游明确判过额度」;排队/限流等非额度冷却不得谎称没额度
|
|
109
|
+
// Note: 判定收紧(全号才报 quota_exhausted,其余如实返回/报冷却中)— 见 .agents/notes/implemented/bug-fix/2026-09-28-qoder-quota-cooldown-switch.md
|
|
110
|
+
async function chat(body, opts) {
|
|
111
|
+
// scope=reqId:同一次客户端请求的多次上游调用(空转重试)粘同一个号
|
|
112
|
+
const scope = opts?.reqId ? `req:${opts.reqId}` : null;
|
|
113
|
+
const stripped = { ...body };
|
|
114
|
+
if (typeof stripped.model === "string" && stripped.model.startsWith("qoder/")) stripped.model = stripped.model.slice(6);
|
|
115
|
+
const badAuth = (n) => n === 401 || n === 403 || n === 429 || n >= 500;
|
|
116
|
+
const total = ring.keys.length;
|
|
117
|
+
// 换号上限:每次 continue 必然冷却掉一个号,故最多 total 轮;超出即退出(收敛保证)
|
|
118
|
+
let lastRes = null;
|
|
119
|
+
for (let hop = 0; hop <= total; hop++) {
|
|
120
|
+
const picked = pickSession(scope);
|
|
121
|
+
if (!picked) break;
|
|
122
|
+
const pickedKey = picked.key || "";
|
|
123
|
+
// 该号额度已耗尽且仍在冷却 → 不再打上游(打也是同一堵墙)。另有可用号时继续换,不轻易断言全灭。
|
|
124
|
+
if (exhaustedByQuota.has(pickedKey)) {
|
|
125
|
+
if (ring.isCooling(pickedKey)) {
|
|
126
|
+
if (ring.available() > 0) continue;
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
exhaustedByQuota.delete(pickedKey);
|
|
130
|
+
}
|
|
131
|
+
let res;
|
|
132
|
+
try {
|
|
133
|
+
res = await chatSvc.runChat(stripped, picked.sess, picked.region, picked.pick);
|
|
134
|
+
} catch (e) {
|
|
135
|
+
try { ring.onError(pickedKey); } catch {}
|
|
136
|
+
throw e;
|
|
137
|
+
}
|
|
138
|
+
const st = res?.status ?? 0;
|
|
139
|
+
// 流式路径把上游非 200 整形成 200 + 流内 error(对外契约),真实状态码只能从回显头取;
|
|
140
|
+
// 否则坏号(401/403/429/5xx)永不冷却,粘号还会把重试继续粘在这个坏号上。
|
|
141
|
+
const ust = Number(res?.headers?.get?.("x-mslxdff-qoder-upstream-status")) || 0;
|
|
142
|
+
const isQuota = res?.headers?.get?.("x-mslxdff-qoder-quota") === "1";
|
|
143
|
+
const isQueued = res?.headers?.get?.("x-mslxdff-qoder-queued") === "1";
|
|
144
|
+
const bad = badAuth(st) ? st : (badAuth(ust) ? ust : 0);
|
|
145
|
+
// 冷却三档:额度(按天重置,最长)> 排队(队列没放行前别再送回,默认 5min)> 其它(普通短冷却)。
|
|
146
|
+
// 排队单独成档的依据:现网 31h 窗口 global 区 176 发被判决 129 发且跨小时不复现恢复,
|
|
147
|
+
// 30s 短冷却等于每 30s 白烧一发;但它不是「今天没了」,到点必须自动回池,故不得并入额度档。
|
|
148
|
+
if (bad) {
|
|
149
|
+
const coolMs = isQuota ? quotaCooldownMs : (isQueued ? queueCooldownMs : cooldownMs);
|
|
150
|
+
try { ring.onError(pickedKey, coolMs); } catch {}
|
|
151
|
+
if (isQuota) exhaustedByQuota.add(pickedKey);
|
|
152
|
+
// 冷却是个决定:钉在响应上,管线写进模型日志(cooled=<status>)
|
|
153
|
+
try { res.headers?.set?.("x-mslxdff-qoder-cooldown", String(bad)); } catch {}
|
|
154
|
+
}
|
|
155
|
+
// 额度耗尽 / 排队:本号这一发已废,只要还有别的号就当场换号重发,别把空流或排队判决递给客户端。
|
|
156
|
+
// (跨请求那侧不用改:长冷却让 sticky 下次因 isCooling 自动 switch。)
|
|
157
|
+
if (isQuota || isQueued) {
|
|
158
|
+
lastRes = res;
|
|
159
|
+
if (ring.available() > 0) continue;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
return res;
|
|
163
|
+
}
|
|
164
|
+
// 收口按「真实全景」分档,不得把「非额度冷却」误报成没额度:
|
|
165
|
+
// 仅当每一个号都被上游明确判过额度,才是真的全号额度耗尽。
|
|
166
|
+
if (total > 0 && ring.keys.every((k) => exhaustedByQuota.has(k))) return quotaExhaustedResponse();
|
|
167
|
+
if (lastRes) return lastRes; // 有上游响应 → 如实透出(含上游真错)
|
|
168
|
+
if (!ring.available()) return allCoolingResponse(); // 全在冷却但非额度 → 冷却中提示
|
|
169
|
+
return new Response(JSON.stringify({ error: { message: "qoder: 无可用账号 — 先跑 mslxdff -provider qoder login", type: "auth_error" } }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 额度耗尽提示(每一个号都被上游明确判过额度):继续等只会更糟,让调用方立刻知道要换号/等明天
|
|
173
|
+
function quotaExhaustedResponse() {
|
|
174
|
+
return new Response(JSON.stringify({
|
|
175
|
+
error: { message: "qoder: 所有账号额度已用完(Billing daily count exceeded),请更换账号或明日再试", type: "quota_exhausted" },
|
|
176
|
+
}), { status: 429, headers: { "Content-Type": "application/json", "x-mslxdff-qoder-quota-exhausted": "1" } });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 冷却中(非额度原因):如实说"暂不可用",不得声称额度耗尽
|
|
180
|
+
function allCoolingResponse() {
|
|
181
|
+
return new Response(JSON.stringify({
|
|
182
|
+
error: { message: "qoder: 账号暂不可用(上游限流/排队冷却中),请稍后重试", type: "all_cooling" },
|
|
183
|
+
}), { status: 429, headers: { "Content-Type": "application/json", "x-mslxdff-qoder-all-cooling": "1" } });
|
|
184
|
+
}
|
|
185
|
+
// 全号聚合:双号分属 cn/global 两区,模型表各不同(cn 14 个/global 15 个);
|
|
186
|
+
// 只取单号会漏另一区(如轮询到 global 就看不到 cn 独有的 q37fmodel/gm51model)。
|
|
187
|
+
// 按 id 并集去重,只返回 enable=true 的可调用模型(过滤已下沉到 models.js)。
|
|
188
|
+
function eachCred() {
|
|
189
|
+
const out = [];
|
|
190
|
+
const seen = new Set();
|
|
191
|
+
const push = (blob, auth) => {
|
|
192
|
+
if (!blob?.deviceToken || seen.has(blob.deviceToken)) return;
|
|
193
|
+
seen.add(blob.deviceToken);
|
|
194
|
+
out.push({ blob, auth: auth || {} });
|
|
195
|
+
};
|
|
196
|
+
for (const k of keys) {
|
|
197
|
+
const blob = accountFromBlob(k);
|
|
198
|
+
if (!blob?.deviceToken) continue;
|
|
199
|
+
const auth = authList.find((a) => String(a?.refreshToken || "") === String(blob.refreshToken || "")) || authList[0] || {};
|
|
200
|
+
push(blob, auth);
|
|
201
|
+
}
|
|
202
|
+
if (!out.length) {
|
|
203
|
+
// keys 为空但 auth 目录有号(如 state 被外部改写):从落盘 doc 合成凭据,目录不断即可出列表
|
|
204
|
+
try {
|
|
205
|
+
for (const { uid, doc } of listAccountDocs()) {
|
|
206
|
+
const a = doc?.auth || {};
|
|
207
|
+
if (!a.deviceToken) continue;
|
|
208
|
+
push({ deviceToken: a.deviceToken, refreshToken: a.refreshToken || "" },
|
|
209
|
+
{ uid, name: doc?.account?.name || "", region: a.region || "global", refreshToken: a.refreshToken || "" });
|
|
210
|
+
}
|
|
211
|
+
} catch {}
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function listModels() {
|
|
217
|
+
const creds = eachCred();
|
|
218
|
+
if (!creds.length) return [];
|
|
219
|
+
const seen = new Set();
|
|
220
|
+
const out = [];
|
|
221
|
+
for (const { blob, auth } of creds) {
|
|
222
|
+
const region = normalizeRegion(region0 || auth.region || "global");
|
|
223
|
+
const sess = buildSessionFor({ ...blob, uid: auth.uid, name: auth.name, region });
|
|
224
|
+
try {
|
|
225
|
+
const list = await modelsSvc.listModels(sess, region);
|
|
226
|
+
for (const m of list) {
|
|
227
|
+
if (!m?.id || seen.has(m.id)) continue;
|
|
228
|
+
seen.add(m.id);
|
|
229
|
+
out.push(m);
|
|
230
|
+
}
|
|
231
|
+
} catch {}
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function preheat() {
|
|
237
|
+
try {
|
|
238
|
+
const list = await listModels();
|
|
239
|
+
return list.length ? { ok: true } : { ok: false, error: "no account" };
|
|
240
|
+
} catch (e) { return { ok: false, error: String(e?.message || e).slice(0, 120) }; }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function close() {}
|
|
244
|
+
async function chatWithKeys(body, keysOverride, opts) {
|
|
245
|
+
const tmp = createQoderProvider({ id, apiKeys: keysOverride, file, fetchImpl, cooldownMs, queueCooldownMs, connectTimeoutMs, sharedModelsSvc: modelsSvc });
|
|
246
|
+
return tmp.chat(body, opts);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return { id, chat, chatWithKeys, listModels, preheat, close, keyRing: ring, baseUrl: "qoder://native", region: normalizeRegion(region || authList[0]?.region) };
|
|
250
250
|
}
|