mslxdff 0.1.231 → 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.
Files changed (117) hide show
  1. package/bin/mslxdff.js +3 -3
  2. package/package.json +1 -1
  3. package/src/auto.js +264 -254
  4. package/src/bench/cline-bench.js +42 -42
  5. package/src/bench/probe.js +70 -70
  6. package/src/bench/report.js +162 -162
  7. package/src/bench/runner.js +77 -77
  8. package/src/bench/via-probe.js +124 -124
  9. package/src/bench/via-routes.js +87 -87
  10. package/src/bench/workbuddy-bench.js +54 -54
  11. package/src/chat/engine.js +160 -160
  12. package/src/chat/gateway.js +163 -163
  13. package/src/chat/orchestrator.js +234 -234
  14. package/src/chat/prompt.js +70 -70
  15. package/src/chat/repl.js +88 -88
  16. package/src/chat/terminal.js +135 -135
  17. package/src/chat/tools.js +306 -306
  18. package/src/chat-pipeline/index.js +155 -145
  19. package/src/chat-pipeline/policy.js +84 -84
  20. package/src/cli/commands/group.js +266 -266
  21. package/src/cli/commands/model/picks.js +50 -50
  22. package/src/cli/commands/provider/bench-via.js +247 -247
  23. package/src/cli/commands/provider/bench.js +141 -141
  24. package/src/cli/commands/provider/index.js +139 -139
  25. package/src/cli/commands/provider/models.js +149 -149
  26. package/src/cli/commands/provider/qwenwork-login.js +119 -119
  27. package/src/cli/commands/provider/raccoon-quota.js +156 -156
  28. package/src/cli/commands/sync.js +310 -310
  29. package/src/cli/status.js +279 -279
  30. package/src/daemon.js +246 -246
  31. package/src/model-capabilities/enrich.js +86 -86
  32. package/src/model-capabilities/index.js +183 -183
  33. package/src/model-capabilities/parse.js +70 -70
  34. package/src/model-trace.js +10 -2
  35. package/src/models.js +292 -225
  36. package/src/peers.js +345 -345
  37. package/src/providers/agentrouter.js +238 -238
  38. package/src/providers/base.js +164 -164
  39. package/src/providers/cline/auth.js +228 -228
  40. package/src/providers/cline/chat.js +307 -307
  41. package/src/providers/cline.js +2 -2
  42. package/src/providers/codearts/chat.js +207 -207
  43. package/src/providers/dispatcher.js +147 -147
  44. package/src/providers/globalqwenwork/cosy.js +123 -123
  45. package/src/providers/globalqwenwork/crypto.js +218 -218
  46. package/src/providers/globalqwenwork/http.js +20 -20
  47. package/src/providers/globalqwenwork/rsa.js +54 -54
  48. package/src/providers/globalqwenwork/sse.js +268 -268
  49. package/src/providers/globalqwenwork/stream.js +130 -130
  50. package/src/providers/globalqwenwork/upstream.js +122 -122
  51. package/src/providers/keyring.js +60 -60
  52. package/src/providers/qoder/account-store.js +18 -0
  53. package/src/providers/qoder/chat.js +292 -203
  54. package/src/providers/qoder/checkin.js +25 -12
  55. package/src/providers/qoder/index.js +311 -249
  56. package/src/providers/qoder/models.js +64 -2
  57. package/src/providers/qoder/oauth.js +42 -1
  58. package/src/providers/qoder/payload.js +116 -80
  59. package/src/providers/qoder/request.js +7 -2
  60. package/src/providers/qoder/session.js +26 -10
  61. package/src/providers/qoder/sse.js +233 -145
  62. package/src/providers/qoder/sticky.js +5 -2
  63. package/src/providers/qoder/wire-messages.js +242 -0
  64. package/src/providers/qwenwork/account-store.js +133 -133
  65. package/src/providers/qwenwork/constants.js +67 -67
  66. package/src/providers/qwenwork/cosy.js +120 -120
  67. package/src/providers/qwenwork/crypto.js +218 -218
  68. package/src/providers/qwenwork/http.js +20 -20
  69. package/src/providers/qwenwork/index.js +337 -337
  70. package/src/providers/qwenwork/payload.js +144 -144
  71. package/src/providers/qwenwork/rsa.js +54 -54
  72. package/src/providers/qwenwork/sse.js +268 -268
  73. package/src/providers/qwenwork/stream.js +130 -130
  74. package/src/providers/qwenwork/upstream.js +120 -120
  75. package/src/providers/qwenwork.js +1 -1
  76. package/src/providers/raccoon/chat.js +1 -1
  77. package/src/providers/raccoon/const.js +219 -202
  78. package/src/providers/raccoon/credits.js +285 -285
  79. package/src/providers/raccoon/index.js +15 -2
  80. package/src/providers/raccoon/models.js +68 -17
  81. package/src/providers/registry.js +89 -89
  82. package/src/providers/workbuddy/auth.js +16 -0
  83. package/src/providers/workbuddy/chat.js +298 -264
  84. package/src/providers/workbuddy/client-headers.js +148 -0
  85. package/src/providers/workbuddy/desktop-info.js +17 -1
  86. package/src/providers/workbuddy/models.js +8 -20
  87. package/src/providers/workbuddy/reshape.js +152 -152
  88. package/src/providers/workbuddy.js +2 -2
  89. package/src/reasoning.js +32 -32
  90. package/src/routes/chat/broadband-handler.js +80 -80
  91. package/src/routes/chat/gateway.js +46 -46
  92. package/src/routes/chat/relay-pipeline.js +282 -282
  93. package/src/routes/chat/via-route-handler.js +156 -156
  94. package/src/routes/hedge.js +260 -260
  95. package/src/routes/models-route.js +167 -167
  96. package/src/routes/peers.js +373 -373
  97. package/src/routes/stream.js +364 -347
  98. package/src/runtime/bootstrap.js +75 -75
  99. package/src/runtime/keep-warm.js +135 -135
  100. package/src/runtime/provider-gate.js +75 -75
  101. package/src/runtime/providers-setup.js +187 -187
  102. package/src/runtime/raccoon-checkin.js +175 -175
  103. package/src/server.js +85 -85
  104. package/src/state/schemas/allowlist.js +92 -92
  105. package/src/sync-opencode.js +280 -280
  106. package/src/transport/index.js +244 -244
  107. package/src/transport/pool.js +56 -56
  108. package/src/transport/retry.js +24 -24
  109. package/src/transport/sse.js +93 -93
  110. package/src/upstream-probe/display.js +52 -52
  111. package/src/upstream-probe/probe.js +49 -49
  112. package/src/upstream-probe/rotate.js +110 -110
  113. package/src/upstream-probe/start.js +45 -45
  114. package/src/upstream.js +355 -355
  115. package/docs/cli_help_mini.md +0 -148
  116. package/src/providers/AGENTS.md +0 -49
  117. package/src/routes/AGENTS.md +0 -37
@@ -1,250 +1,312 @@
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 { refreshDeviceToken } from "./oauth.js";
18
+ import { updateQoderAccountTokens } from "./account-store.js";
19
+ import { compatFetch } from "../../compat.js";
20
+
21
+ function buildSessionFor(cred) {
22
+ const uid = cred.uid || cred.deviceToken.slice(0, 8);
23
+ const seed = fingerprintSeed(uid, cred.deviceToken);
24
+ const identity = {
25
+ name: cred.name || "", aid: uid, uid, yxUid: "",
26
+ organizationId: cred.organizationId || "", organizationName: cred.organizationName || "",
27
+ userType: cred.userType || "personal_standard",
28
+ securityOauthToken: cred.deviceToken, refreshToken: cred.refreshToken || "",
29
+ };
30
+ return newSession(identity, deriveMachineId(seed), deriveMachineToken(seed), deriveMachineType(seed));
31
+ }
32
+
33
+ export function createQoderProvider({
34
+ id = "qoder",
35
+ apiKeys,
36
+ file,
37
+ region,
38
+ fetchImpl,
39
+ sharedModelsSvc, // chatWithKeys 等临时门面复用主实例目录快照(评审路1 P1#3:冷缓存否则恒 false)
40
+ cooldownMs = envInt("MSLXDFF_QODER_COOLDOWN_MS", 30_000),
41
+ // 额度耗尽(code 110)按天重置:短冷却等于反复撞死号,故单独配长冷却(默认 1h)
42
+ quotaCooldownMs = envInt("MSLXDFF_QODER_QUOTA_COOLDOWN_MS", 3600_000),
43
+ // 排队(10605/isQueued)单独一档:队列没放行前反复送回就是白烧(实测跨小时不恢复),默认 5min。
44
+ // 上游口播的 retryAfterSeconds=30 只当参考值,不当本档上限——按 30s 回池等于回到旧行为。
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),
48
+ connectTimeoutMs = Number(process.env.MSLXDFF_QODER_TIMEOUT_MS) || 120_000,
49
+ } = {}) {
50
+ const keys = (() => {
51
+ if (Array.isArray(apiKeys) && apiKeys.length) return [...new Set(apiKeys.map((k) => String(k).trim()).filter(Boolean))];
52
+ try { return loadProviderKeys(id, file ? { file } : {}); } catch { return []; }
53
+ })();
54
+ let authList = [];
55
+ try { authList = loadProviderConfig(id, file ? { file } : {})?.auths || []; } catch {}
56
+ // auths 行的 region 会被 state.normalizeAuths 剥掉:每号真实 region 从 auths/qoder-<uid>.json 读(单一源)
57
+ try {
58
+ const docs = listAccountDocs();
59
+ for (const d of docs) {
60
+ const row = authList.find((a) => String(a?.uid) === String(d.uid));
61
+ if (row) row.region = d.doc?.auth?.region || "global";
62
+ else authList.push({ uid: d.uid, refreshToken: d.doc?.auth?.refreshToken || "", region: d.doc?.auth?.region || "global", name: d.doc?.account?.name || "" });
63
+ }
64
+ } catch {}
65
+ const ring = createKeyRing(keys, { cooldownMs });
66
+ // 额度耗尽号集合(本次进程内):全号在此集合 → 无可用号时回额度提示而非"无账号"401
67
+ const exhaustedByQuota = new Set();
68
+ if (!fetchImpl) fetchImpl = compatFetch;
69
+
70
+
71
+
72
+ // 选号:同一客户端请求内粘住(重试不换号),只有该号被冷却(401/403/429/5xx)才换下一个;
73
+ // 无 scope(拿不到 reqId,如独立的 models 探活)时退回原 round-robin。
74
+ const pickKey = createStickyPicker({
75
+ pick: () => ring.next() || keys[0] || "",
76
+ isCooling: (k) => ring.isCooling(k),
77
+ ttlMs: envInt("MSLXDFF_QODER_STICKY_MS", 600_000),
78
+ });
79
+
80
+ // 凭据 → 会话(会话含 RSA/AES 临时密钥,进程内可复用;此处按请求轻建,成本低)
81
+ function pickSession(scope) {
82
+ let decision = "new";
83
+ const key = pickKey(scope, (d) => { decision = d; });
84
+ // 全部号都在冷却时 pick() 只能兜底取 keys[0]:这是"被迫选择",必须与正常轮转区分开
85
+ if (ring.available() === 0) decision = "forced";
86
+ const blob = accountFromBlob(key);
87
+ if (!blob?.deviceToken) return null;
88
+ const auth = authList.find((a) => String(a?.refreshToken || "") === String(blob.refreshToken || "")) || authList[0] || {};
89
+ const region = normalizeRegion(region0 || auth.region || "global");
90
+ const sess = buildSessionFor({ ...blob, uid: auth.uid, name: auth.name, region });
91
+ // key/pick 带出:onError(key) 要冷却"刚用过的这个号";pick 让日志能写"这次为什么是它"
92
+ return { sess, region, key, pick: decision };
93
+ }
94
+ const region0 = region; // 构造参数优先
95
+
96
+ const chatSvc = createChatService({
97
+ id,
98
+ fetchImpl,
99
+ // 目录驱动请求(只读 peekModels 缓存,chat 路径恒 0 额外上游调用;preheat 负责灌缓存)
100
+ getModelMeta: (sess2, reg, key) => {
101
+ const list = modelsSvc.peekModels(reg);
102
+ return list ? list.find((m) => m.id === `${id}/${key}` || m.id === key) || null : null;
103
+ },
104
+ timeoutMs: connectTimeoutMs,
105
+ });
106
+ // models 服务按号选区:构造时无固定 region,listModels(sess, region) 动态传
107
+ const modelsSvc = sharedModelsSvc || createModelsService({ id, fetchImpl });
108
+
109
+ // 失败冷却:401/403/429/5xx 冷却当前号(cooldownMs,默认 30s),坏号不再参与轮换。
110
+ // 额度耗尽(quota)走长冷却(quotaCooldownMs,默认 1h)——额度按天重置,短冷却等于反复撞死号。
111
+ // 额度错在 provider 内直接换号重发(不依赖外层空转重试链路:那条链路在非流式路径不触发)。
112
+ // 收口只认「每个号都被上游明确判过额度」;排队/限流等非额度冷却不得谎称没额度
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
+
146
+ async function chat(body, opts) {
147
+ // scope=reqId:同一次客户端请求的多次上游调用(空转重试)粘同一个号
148
+ const scope = opts?.reqId ? `req:${opts.reqId}` : null;
149
+ const stripped = { ...body };
150
+ if (typeof stripped.model === "string" && stripped.model.startsWith("qoder/")) stripped.model = stripped.model.slice(6);
151
+ const badAuth = (n) => n === 401 || n === 403 || n === 429 || n >= 500;
152
+ const total = ring.keys.length;
153
+ // 换号上限:每次 continue 必然冷却掉一个号,故最多 total 轮;超出即退出(收敛保证)
154
+ let lastRes = null;
155
+ for (let hop = 0; hop <= total; hop++) {
156
+ const picked = pickSession(scope);
157
+ if (!picked) break;
158
+ const pickedKey = picked.key || "";
159
+ // 该号额度已耗尽且仍在冷却 → 不再打上游(打也是同一堵墙)。另有可用号时继续换,不轻易断言全灭。
160
+ if (exhaustedByQuota.has(pickedKey)) {
161
+ if (ring.isCooling(pickedKey)) {
162
+ if (ring.available() > 0) continue;
163
+ break;
164
+ }
165
+ exhaustedByQuota.delete(pickedKey);
166
+ }
167
+ let res;
168
+ try {
169
+ res = await chatSvc.runChat(stripped, picked.sess, picked.region, picked.pick);
170
+ } catch (e) {
171
+ try { ring.onError(pickedKey); } catch {}
172
+ throw e;
173
+ }
174
+ const st = res?.status ?? 0;
175
+ // 流式路径把上游非 200 整形成 200 + 流内 error(对外契约),真实状态码只能从回显头取;
176
+ // 否则坏号(401/403/429/5xx)永不冷却,粘号还会把重试继续粘在这个坏号上。
177
+ const ust = Number(res?.headers?.get?.("x-mslxdff-qoder-upstream-status")) || 0;
178
+ const isQuota = res?.headers?.get?.("x-mslxdff-qoder-quota") === "1";
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
+ }
206
+ const bad = badAuth(st) ? st : (badAuth(ust) ? ust : 0);
207
+ // 冷却三档:额度(按天重置,最长)> 排队(队列没放行前别再送回,默认 5min)> 其它(普通短冷却)。
208
+ // 排队单独成档的依据:现网 31h 窗口 global 区 176 发被判决 129 发且跨小时不复现恢复,
209
+ // 30s 短冷却等于每 30s 白烧一发;但它不是「今天没了」,到点必须自动回池,故不得并入额度档。
210
+ if (bad) {
211
+ const coolMs = isQuota ? quotaCooldownMs : (isQueued ? queueCooldownMs : cooldownMs);
212
+ try { ring.onError(pickedKey, coolMs); } catch {}
213
+ if (isQuota) exhaustedByQuota.add(pickedKey);
214
+ // 冷却是个决定:钉在响应上,管线写进模型日志(cooled=<status>)
215
+ try { res.headers?.set?.("x-mslxdff-qoder-cooldown", String(bad)); } catch {}
216
+ }
217
+ // 额度耗尽 / 排队:本号这一发已废,只要还有别的号就当场换号重发,别把空流或排队判决递给客户端。
218
+ // (跨请求那侧不用改:长冷却让 sticky 下次因 isCooling 自动 switch。)
219
+ if (isQuota || isQueued) {
220
+ lastRes = res;
221
+ if (ring.available() > 0) continue;
222
+ break;
223
+ }
224
+ return res;
225
+ }
226
+ // 收口按「真实全景」分档,不得把「非额度冷却」误报成没额度:
227
+ // 仅当每一个号都被上游明确判过额度,才是真的全号额度耗尽。
228
+ if (total > 0 && ring.keys.every((k) => exhaustedByQuota.has(k))) return quotaExhaustedResponse();
229
+ if (lastRes) return lastRes; // 有上游响应 → 如实透出(含上游真错)
230
+ if (!ring.available()) return allCoolingResponse(); // 全在冷却但非额度 → 冷却中提示
231
+ return new Response(JSON.stringify({ error: { message: "qoder: 无可用账号 — 先跑 mslxdff -provider qoder login", type: "auth_error" } }), { status: 401, headers: { "Content-Type": "application/json" } });
232
+ }
233
+
234
+ // 额度耗尽提示(每一个号都被上游明确判过额度):继续等只会更糟,让调用方立刻知道要换号/等明天
235
+ function quotaExhaustedResponse() {
236
+ return new Response(JSON.stringify({
237
+ error: { message: "qoder: 所有账号额度已用完(Billing daily count exceeded),请更换账号或明日再试", type: "quota_exhausted" },
238
+ }), { status: 429, headers: { "Content-Type": "application/json", "x-mslxdff-qoder-quota-exhausted": "1" } });
239
+ }
240
+
241
+ // 冷却中(非额度原因):如实说"暂不可用",不得声称额度耗尽
242
+ function allCoolingResponse() {
243
+ return new Response(JSON.stringify({
244
+ error: { message: "qoder: 账号暂不可用(上游限流/排队冷却中),请稍后重试", type: "all_cooling" },
245
+ }), { status: 429, headers: { "Content-Type": "application/json", "x-mslxdff-qoder-all-cooling": "1" } });
246
+ }
247
+ // 全号聚合:双号分属 cn/global 两区,模型表各不同(cn 14 个/global 15 个);
248
+ // 只取单号会漏另一区(如轮询到 global 就看不到 cn 独有的 q37fmodel/gm51model)。
249
+ // 按 id 并集去重,只返回 enable=true 的可调用模型(过滤已下沉到 models.js)。
250
+ function eachCred() {
251
+ const out = [];
252
+ const seen = new Set();
253
+ const push = (blob, auth) => {
254
+ if (!blob?.deviceToken || seen.has(blob.deviceToken)) return;
255
+ seen.add(blob.deviceToken);
256
+ out.push({ blob, auth: auth || {} });
257
+ };
258
+ for (const k of keys) {
259
+ const blob = accountFromBlob(k);
260
+ if (!blob?.deviceToken) continue;
261
+ const auth = authList.find((a) => String(a?.refreshToken || "") === String(blob.refreshToken || "")) || authList[0] || {};
262
+ push(blob, auth);
263
+ }
264
+ if (!out.length) {
265
+ // keys 为空但 auth 目录有号(如 state 被外部改写):从落盘 doc 合成凭据,目录不断即可出列表
266
+ try {
267
+ for (const { uid, doc } of listAccountDocs()) {
268
+ const a = doc?.auth || {};
269
+ if (!a.deviceToken) continue;
270
+ push({ deviceToken: a.deviceToken, refreshToken: a.refreshToken || "" },
271
+ { uid, name: doc?.account?.name || "", region: a.region || "global", refreshToken: a.refreshToken || "" });
272
+ }
273
+ } catch {}
274
+ }
275
+ return out;
276
+ }
277
+
278
+ async function listModels() {
279
+ const creds = eachCred();
280
+ if (!creds.length) return [];
281
+ const seen = new Set();
282
+ const out = [];
283
+ for (const { blob, auth } of creds) {
284
+ const region = normalizeRegion(region0 || auth.region || "global");
285
+ const sess = buildSessionFor({ ...blob, uid: auth.uid, name: auth.name, region });
286
+ try {
287
+ const list = await modelsSvc.listModels(sess, region);
288
+ for (const m of list) {
289
+ if (!m?.id || seen.has(m.id)) continue;
290
+ seen.add(m.id);
291
+ out.push(m);
292
+ }
293
+ } catch {}
294
+ }
295
+ return out;
296
+ }
297
+
298
+ async function preheat() {
299
+ try {
300
+ const list = await listModels();
301
+ return list.length ? { ok: true } : { ok: false, error: "no account" };
302
+ } catch (e) { return { ok: false, error: String(e?.message || e).slice(0, 120) }; }
303
+ }
304
+
305
+ async function close() {}
306
+ async function chatWithKeys(body, keysOverride, opts) {
307
+ const tmp = createQoderProvider({ id, apiKeys: keysOverride, file, fetchImpl, cooldownMs, queueCooldownMs, connectTimeoutMs, sharedModelsSvc: modelsSvc });
308
+ return tmp.chat(body, opts);
309
+ }
310
+
311
+ return { id, chat, chatWithKeys, listModels, preheat, close, keyRing: ring, baseUrl: "qoder://native", region: normalizeRegion(region || authList[0]?.region) };
250
312
  }