mslxdff 0.1.155 → 0.1.158

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 (151) hide show
  1. package/bin/mslxdff.js +4 -4
  2. package/docs/ARCHITECTURE.md +426 -0
  3. package/docs/FEATURE_TREE.md +164 -0
  4. package/docs/MOBILE.md +82 -0
  5. package/docs/adr/0001-reasoning-content-injection.md +14 -0
  6. package/docs/adr/0002-models-free-filter.md +12 -0
  7. package/docs/adr/0003-zero-state-no-auth.md +10 -0
  8. package/docs/adr/0004-bearer-token.md +18 -0
  9. package/docs/adr/0005-peer-mesh.md +53 -0
  10. package/docs/adr/0006-broadband-member.md +103 -0
  11. package/docs/adr/0007-multi-provider-prefix.md +25 -0
  12. package/docs/adr/0008-share-keys-to-peers.md +52 -0
  13. package/docs/adr/0009-chat-repl.md +37 -0
  14. package/docs/adr/0010-allowlist.md +36 -0
  15. package/docs/adr/0011-broadband-stream.md +30 -0
  16. package/docs/adr/0012-responses-endpoint-codex-sync.md +48 -0
  17. package/docs/adr/0013-node16-compat.md +41 -0
  18. package/docs/adr/0014-deepseek-provider.md +52 -0
  19. package/docs/adr/0015-upstream-probe-routing.md +50 -0
  20. package/docs/adr/0016-model-capabilities.md +27 -0
  21. package/docs/adr/0017-ai-sdk-upstream-engine.md +59 -0
  22. package/docs/adr/0018-zen-client-identity.md +52 -0
  23. package/docs/adr/0019-share-keys-always-lend.md +59 -0
  24. package/docs/adr/0020-zen-free-lane-agent-shape.md +52 -0
  25. package/docs/adr/0021-usage-report-jsonl.md +47 -0
  26. package/docs/adr/0022-models-capability-merge.md +61 -0
  27. package/docs/adr/0023-key-provider-default-direct.md +58 -0
  28. package/docs/adr/0024-node18-baseline.md +63 -0
  29. package/docs/adr/0025-workbuddy-authdir-follows-state.md +71 -0
  30. package/docs/adr/0026-cline-provider-id-unify.md +67 -0
  31. package/docs/adr/0027-codearts-provider.md +48 -0
  32. package/docs/adr/0028-traework-provider.md +34 -0
  33. package/docs/adr/0029-qoder-native-provider.md +60 -0
  34. package/docs/adr/0030-models-list-scoped-by-picks.md +53 -0
  35. package/docs/adr/0031-qoder-true-streaming.md +50 -0
  36. package/docs/adr/0032-generic-responses-channel.md +72 -0
  37. package/docs/adr/0033-cline-allowlist-auto-sync.md +75 -0
  38. package/docs/adr/0034-request-level-human-readable-observability.md +60 -0
  39. package/docs/adr/0035-sdk-channel-headers-timeout.md +49 -0
  40. package/docs/adr/0036-qoder-per-request-sticky-account.md +82 -0
  41. package/docs/adr/0037-qwenwork-independent-provider.md +82 -0
  42. package/docs/adr/0038-zcode-provider.md +140 -0
  43. package/docs/agents/domain.md +51 -0
  44. package/docs/agents/issue-tracker.md +30 -0
  45. package/docs/agents/triage-labels.md +15 -0
  46. package/docs/cli_help.md +1391 -0
  47. package/docs/cli_help_mini.md +133 -0
  48. package/docs/plans/bench-via-latency-2026-09-01.md +215 -0
  49. package/docs/plugins.md +187 -0
  50. package/package.json +1 -1
  51. package/src/auto.js +254 -254
  52. package/src/bench/cline-bench.js +42 -42
  53. package/src/bench/probe.js +70 -70
  54. package/src/bench/report.js +162 -162
  55. package/src/bench/runner.js +77 -77
  56. package/src/bench/via-probe.js +124 -124
  57. package/src/bench/via-routes.js +87 -87
  58. package/src/bench/workbuddy-bench.js +54 -54
  59. package/src/chat/engine.js +160 -160
  60. package/src/chat/gateway.js +163 -163
  61. package/src/chat/orchestrator.js +234 -234
  62. package/src/chat/prompt.js +70 -70
  63. package/src/chat/repl.js +88 -88
  64. package/src/chat/terminal.js +135 -135
  65. package/src/chat/tools.js +306 -306
  66. package/src/chat-pipeline/index.js +123 -123
  67. package/src/chat-pipeline/policy.js +76 -76
  68. package/src/chat-pipeline/serial-trial.js +210 -210
  69. package/src/cli/commands/group.js +249 -249
  70. package/src/cli/commands/model/list-providers.js +4 -3
  71. package/src/cli/commands/model/list-render.js +7 -9
  72. package/src/cli/commands/model/list-sort.js +52 -0
  73. package/src/cli/commands/model/list-tty.js +76 -0
  74. package/src/cli/commands/model/list.js +5 -62
  75. package/src/cli/commands/model/picks.js +50 -50
  76. package/src/cli/commands/provider/bench-via.js +247 -247
  77. package/src/cli/commands/provider/bench.js +141 -141
  78. package/src/cli/commands/provider/index.js +124 -116
  79. package/src/cli/commands/provider/models.js +139 -128
  80. package/src/cli/commands/provider/qwenwork-login.js +119 -0
  81. package/src/cli/commands/provider/zcode-login.js +77 -0
  82. package/src/cli/commands/provider/zcode-quota.js +55 -0
  83. package/src/cli/commands/sync.js +232 -232
  84. package/src/cli/provider-row.js +2 -2
  85. package/src/cli/status.js +279 -279
  86. package/src/daemon.js +96 -96
  87. package/src/model-capabilities/enrich.js +86 -86
  88. package/src/model-capabilities/index.js +183 -183
  89. package/src/model-capabilities/parse.js +70 -70
  90. package/src/model-trace.js +1 -0
  91. package/src/models.js +225 -225
  92. package/src/providers/classify.js +1 -1
  93. package/src/providers/cline/auth.js +228 -228
  94. package/src/providers/cline/chat.js +307 -307
  95. package/src/providers/cline.js +2 -2
  96. package/src/providers/keyring.js +60 -56
  97. package/src/providers/qoder/chat.js +183 -100
  98. package/src/providers/qoder/index.js +230 -184
  99. package/src/providers/qoder/sse.js +103 -46
  100. package/src/providers/qoder/sticky.js +1 -0
  101. package/src/providers/qoder/stream.js +32 -9
  102. package/src/providers/qwenwork/account-store.js +133 -0
  103. package/src/providers/qwenwork/constants.js +67 -0
  104. package/src/providers/qwenwork/cosy.js +120 -0
  105. package/src/providers/qwenwork/crypto.js +218 -0
  106. package/src/providers/qwenwork/http.js +20 -0
  107. package/src/providers/qwenwork/index.js +327 -0
  108. package/src/providers/qwenwork/payload.js +142 -0
  109. package/src/providers/qwenwork/rsa.js +54 -0
  110. package/src/providers/qwenwork/sse.js +268 -0
  111. package/src/providers/qwenwork/stream.js +130 -0
  112. package/src/providers/qwenwork/upstream.js +120 -0
  113. package/src/providers/qwenwork.js +1 -0
  114. package/src/providers/registry.js +66 -56
  115. package/src/providers/share-keys.js +2 -2
  116. package/src/providers/workbuddy/chat.js +248 -248
  117. package/src/providers/workbuddy/reshape.js +152 -152
  118. package/src/providers/workbuddy.js +2 -2
  119. package/src/providers/zcode/account-store.js +129 -0
  120. package/src/providers/zcode/auth.js +28 -0
  121. package/src/providers/zcode/chat.js +171 -0
  122. package/src/providers/zcode/const.js +54 -0
  123. package/src/providers/zcode/headers.js +55 -0
  124. package/src/providers/zcode/index.js +124 -0
  125. package/src/providers/zcode/models.js +65 -0
  126. package/src/providers/zcode/oauth.js +120 -0
  127. package/src/providers/zcode/quota.js +176 -0
  128. package/src/providers/zcode/sse.js +179 -0
  129. package/src/reasoning.js +32 -32
  130. package/src/routes/chat/gateway.js +46 -46
  131. package/src/routes/chat/relay-pipeline.js +250 -246
  132. package/src/routes/chat/via-route-handler.js +144 -144
  133. package/src/routes/hedge.js +255 -255
  134. package/src/routes/models-route.js +167 -167
  135. package/src/routes/peers.js +273 -273
  136. package/src/routes/stream.js +438 -399
  137. package/src/runtime/bootstrap.js +45 -45
  138. package/src/runtime/provider-gate.js +33 -30
  139. package/src/runtime/providers-setup.js +165 -165
  140. package/src/server.js +64 -64
  141. package/src/state/schemas/allowlist.js +92 -92
  142. package/src/sync-opencode.js +280 -280
  143. package/src/transport/index.js +244 -244
  144. package/src/transport/pool.js +56 -56
  145. package/src/transport/retry.js +24 -24
  146. package/src/transport/sse.js +93 -93
  147. package/src/upstream-probe/display.js +52 -52
  148. package/src/upstream-probe/probe.js +49 -49
  149. package/src/upstream-probe/rotate.js +110 -110
  150. package/src/upstream-probe/start.js +45 -45
  151. package/src/upstream.js +289 -289
@@ -1,185 +1,231 @@
1
- // qoder provider 门面(原生直连,零桥依赖):
2
- // 多号 = keys[] 里每条 device_token blob(auths/qoder-<uid>.json 落盘由 login 维护);
3
- // 每请求 round-robin 选号 → 建会话 → COSY 签名直调上游;恒 local-only 不借 key。
4
- import { loadProviderKeys, loadProviderConfig } from "../../state.js";
5
- import { listAccountDocs } from "./account-store.js";
6
- import { defaultStateFile } from "../../state/store.js";
7
- import { createKeyRing } from "../keyring.js";
8
- import { envInt } from "../base.js";
9
- import { accountFromBlob } from "./account-store.js";
10
- import { normalizeRegion } from "./constants.js";
11
- import { fingerprintSeed, deriveMachineId, deriveMachineToken, deriveMachineType } from "./fingerprint.js";
12
- import { newSession } from "./session.js";
13
- import { createChatService } from "./chat.js";
14
- import { createStickyPicker } from "./sticky.js";
15
- import { createModelsService } from "./models.js";
16
- import { compatFetch } from "../../compat.js";
17
-
18
- function buildSessionFor(cred) {
19
- const uid = cred.uid || cred.deviceToken.slice(0, 8);
20
- const seed = fingerprintSeed(uid, cred.deviceToken);
21
- const identity = {
22
- name: cred.name || "", aid: uid, uid, yxUid: "",
23
- organizationId: cred.organizationId || "", organizationName: cred.organizationName || "",
24
- userType: cred.userType || "personal_standard",
25
- securityOauthToken: cred.deviceToken, refreshToken: cred.refreshToken || "",
26
- };
27
- return newSession(identity, deriveMachineId(seed), deriveMachineToken(seed), deriveMachineType(seed));
28
- }
29
-
30
- export function createQoderProvider({
31
- id = "qoder",
32
- apiKeys,
33
- file,
34
- region,
35
- fetchImpl,
36
- cooldownMs = envInt("MSLXDFF_QODER_COOLDOWN_MS", 30_000),
37
- connectTimeoutMs = Number(process.env.MSLXDFF_QODER_TIMEOUT_MS) || 120_000,
38
- } = {}) {
39
- const keys = (() => {
40
- if (Array.isArray(apiKeys) && apiKeys.length) return [...new Set(apiKeys.map((k) => String(k).trim()).filter(Boolean))];
41
- try { return loadProviderKeys(id, file ? { file } : {}); } catch { return []; }
42
- })();
43
- let authList = [];
44
- try { authList = loadProviderConfig(id, file ? { file } : {})?.auths || []; } catch {}
45
- // auths 行的 region 会被 state.normalizeAuths 剥掉:每号真实 region 从 auths/qoder-<uid>.json 读(单一源)
46
- try {
47
- const docs = listAccountDocs();
48
- for (const d of docs) {
49
- const row = authList.find((a) => String(a?.uid) === String(d.uid));
50
- if (row) row.region = d.doc?.auth?.region || "global";
51
- else authList.push({ uid: d.uid, refreshToken: d.doc?.auth?.refreshToken || "", region: d.doc?.auth?.region || "global", name: d.doc?.account?.name || "" });
52
- }
53
- } catch {}
54
- const ring = createKeyRing(keys, { cooldownMs });
55
- if (!fetchImpl) fetchImpl = compatFetch;
56
-
57
-
58
-
59
- // 选号:同一客户端请求内粘住(重试不换号),只有该号被冷却(401/403/429/5xx)才换下一个;
60
- // 无 scope(拿不到 reqId,如独立的 models 探活)时退回原 round-robin。
61
- const pickKey = createStickyPicker({
62
- pick: () => ring.next() || keys[0] || "",
63
- isCooling: (k) => ring.isCooling(k),
64
- ttlMs: envInt("MSLXDFF_QODER_STICKY_MS", 600_000),
65
- });
66
-
67
- // 凭据 → 会话(会话含 RSA/AES 临时密钥,进程内可复用;此处按请求轻建,成本低)
68
- function pickSession(scope) {
69
- let decision = "new";
70
- const key = pickKey(scope, (d) => { decision = d; });
71
- // 全部号都在冷却时 pick() 只能兜底取 keys[0]:这是"被迫选择",必须与正常轮转区分开
72
- if (ring.available() === 0) decision = "forced";
73
- const blob = accountFromBlob(key);
74
- if (!blob?.deviceToken) return null;
75
- const auth = authList.find((a) => String(a?.refreshToken || "") === String(blob.refreshToken || "")) || authList[0] || {};
76
- const region = normalizeRegion(region0 || auth.region || "global");
77
- const sess = buildSessionFor({ ...blob, uid: auth.uid, name: auth.name, region });
78
- // key/pick 带出:onError(key) 要冷却"刚用过的这个号";pick 让日志能写"这次为什么是它"
79
- return { sess, region, key, pick: decision };
80
- }
81
- const region0 = region; // 构造参数优先
82
-
83
- const chatSvc = createChatService({ id, fetchImpl, timeoutMs: connectTimeoutMs });
84
- // models 服务按号选区:构造时无固定 region,listModels(sess, region) 动态传
85
- const modelsSvc = createModelsService({ id, fetchImpl });
86
-
87
- // 失败冷却:401/403/429/5xx 冷却当前号(cooldownMs,默认 30s),坏号不再参与轮换。
88
- // 对齐 workbuddy/chat.js 的 onError 语义——此前 qoder 只 next() 不 onError,坏号恒在轮换池里。
89
- // 网络异常(fetch throw)同样冷却。业务错(4xx 内容类)不冷却。
90
- async function chat(body, opts) {
91
- // scope=reqId:同一次客户端请求的多次上游调用(空转重试)粘同一个号
92
- const picked = pickSession(opts?.reqId ? `req:${opts.reqId}` : null);
93
- if (!picked) {
94
- return new Response(JSON.stringify({ error: { message: "qoder: 无可用账号 — 先跑 mslxdff -provider qoder login", type: "auth_error" } }), { status: 401, headers: { "Content-Type": "application/json" } });
95
- }
96
- const pickedKey = picked.key || "";
97
- const stripped = { ...body };
98
- if (typeof stripped.model === "string" && stripped.model.startsWith("qoder/")) stripped.model = stripped.model.slice(6);
99
- let res;
100
- try {
101
- res = await chatSvc.runChat(stripped, picked.sess, picked.region, picked.pick);
102
- } catch (e) {
103
- try { ring.onError(pickedKey); } catch {}
104
- throw e;
105
- }
106
- const badAuth = (n) => n === 401 || n === 403 || n === 429 || n >= 500;
107
- const st = res?.status ?? 0;
108
- // 流式路径把上游非 200 整形成 200 + 流内 error(对外契约),真实状态码只能从回显头取;
109
- // 否则坏号(401/403/429/5xx)永不冷却,粘号还会把重试继续粘在这个坏号上。
110
- const ust = Number(res?.headers?.get?.("x-mslxdff-qoder-upstream-status")) || 0;
111
- const bad = badAuth(st) ? st : (badAuth(ust) ? ust : 0);
112
- if (bad) {
113
- try { ring.onError(pickedKey); } catch {}
114
- // 冷却是个决定:钉在响应上,管线写进模型日志(cooled=<status>)
115
- try { res.headers?.set?.("x-mslxdff-qoder-cooldown", String(bad)); } catch {}
116
- }
117
- return res;
118
- }
119
-
120
- // 全号聚合:双号分属 cn/global 两区,模型表各不同(cn 14 个/global 15 个);
121
- // 只取单号会漏另一区(如轮询到 global 就看不到 cn 独有的 q37fmodel/gm51model)。
122
- // 按 id 并集去重,只返回 enable=true 的可调用模型(过滤已下沉到 models.js)。
123
- function eachCred() {
124
- const out = [];
125
- const seen = new Set();
126
- const push = (blob, auth) => {
127
- if (!blob?.deviceToken || seen.has(blob.deviceToken)) return;
128
- seen.add(blob.deviceToken);
129
- out.push({ blob, auth: auth || {} });
130
- };
131
- for (const k of keys) {
132
- const blob = accountFromBlob(k);
133
- if (!blob?.deviceToken) continue;
134
- const auth = authList.find((a) => String(a?.refreshToken || "") === String(blob.refreshToken || "")) || authList[0] || {};
135
- push(blob, auth);
136
- }
137
- if (!out.length) {
138
- // keys 为空但 auth 目录有号(如 state 被外部改写):从落盘 doc 合成凭据,目录不断即可出列表
139
- try {
140
- for (const { uid, doc } of listAccountDocs()) {
141
- const a = doc?.auth || {};
142
- if (!a.deviceToken) continue;
143
- push({ deviceToken: a.deviceToken, refreshToken: a.refreshToken || "" },
144
- { uid, name: doc?.account?.name || "", region: a.region || "global", refreshToken: a.refreshToken || "" });
145
- }
146
- } catch {}
147
- }
148
- return out;
149
- }
150
-
151
- async function listModels() {
152
- const creds = eachCred();
153
- if (!creds.length) return [];
154
- const seen = new Set();
155
- const out = [];
156
- for (const { blob, auth } of creds) {
157
- const region = normalizeRegion(region0 || auth.region || "global");
158
- const sess = buildSessionFor({ ...blob, uid: auth.uid, name: auth.name, region });
159
- try {
160
- const list = await modelsSvc.listModels(sess, region);
161
- for (const m of list) {
162
- if (!m?.id || seen.has(m.id)) continue;
163
- seen.add(m.id);
164
- out.push(m);
165
- }
166
- } catch {}
167
- }
168
- return out;
169
- }
170
-
171
- async function preheat() {
172
- try {
173
- const list = await listModels();
174
- return list.length ? { ok: true } : { ok: false, error: "no account" };
175
- } catch (e) { return { ok: false, error: String(e?.message || e).slice(0, 120) }; }
176
- }
177
-
178
- async function close() {}
179
- async function chatWithKeys(body, keysOverride, opts) {
180
- const tmp = createQoderProvider({ id, apiKeys: keysOverride, file, fetchImpl, cooldownMs, connectTimeoutMs });
181
- return tmp.chat(body, opts);
182
- }
183
-
184
- 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
+ cooldownMs = envInt("MSLXDFF_QODER_COOLDOWN_MS", 30_000),
38
+ // 额度耗尽(code 110)按天重置:短冷却等于反复撞死号,故单独配长冷却(默认 1h)
39
+ quotaCooldownMs = envInt("MSLXDFF_QODER_QUOTA_COOLDOWN_MS", 3600_000),
40
+ connectTimeoutMs = Number(process.env.MSLXDFF_QODER_TIMEOUT_MS) || 120_000,
41
+ } = {}) {
42
+ const keys = (() => {
43
+ if (Array.isArray(apiKeys) && apiKeys.length) return [...new Set(apiKeys.map((k) => String(k).trim()).filter(Boolean))];
44
+ try { return loadProviderKeys(id, file ? { file } : {}); } catch { return []; }
45
+ })();
46
+ let authList = [];
47
+ try { authList = loadProviderConfig(id, file ? { file } : {})?.auths || []; } catch {}
48
+ // auths 行的 region 会被 state.normalizeAuths 剥掉:每号真实 region 从 auths/qoder-<uid>.json 读(单一源)
49
+ try {
50
+ const docs = listAccountDocs();
51
+ for (const d of docs) {
52
+ const row = authList.find((a) => String(a?.uid) === String(d.uid));
53
+ if (row) row.region = d.doc?.auth?.region || "global";
54
+ else authList.push({ uid: d.uid, refreshToken: d.doc?.auth?.refreshToken || "", region: d.doc?.auth?.region || "global", name: d.doc?.account?.name || "" });
55
+ }
56
+ } catch {}
57
+ const ring = createKeyRing(keys, { cooldownMs });
58
+ // 额度耗尽号集合(本次进程内):全号在此集合 → 无可用号时回额度提示而非"无账号"401
59
+ const exhaustedByQuota = new Set();
60
+ if (!fetchImpl) fetchImpl = compatFetch;
61
+
62
+
63
+
64
+ // 选号:同一客户端请求内粘住(重试不换号),只有该号被冷却(401/403/429/5xx)才换下一个;
65
+ // 无 scope(拿不到 reqId,如独立的 models 探活)时退回原 round-robin。
66
+ const pickKey = createStickyPicker({
67
+ pick: () => ring.next() || keys[0] || "",
68
+ isCooling: (k) => ring.isCooling(k),
69
+ ttlMs: envInt("MSLXDFF_QODER_STICKY_MS", 600_000),
70
+ });
71
+
72
+ // 凭据 → 会话(会话含 RSA/AES 临时密钥,进程内可复用;此处按请求轻建,成本低)
73
+ function pickSession(scope) {
74
+ let decision = "new";
75
+ const key = pickKey(scope, (d) => { decision = d; });
76
+ // 全部号都在冷却时 pick() 只能兜底取 keys[0]:这是"被迫选择",必须与正常轮转区分开
77
+ if (ring.available() === 0) decision = "forced";
78
+ const blob = accountFromBlob(key);
79
+ if (!blob?.deviceToken) return null;
80
+ const auth = authList.find((a) => String(a?.refreshToken || "") === String(blob.refreshToken || "")) || authList[0] || {};
81
+ const region = normalizeRegion(region0 || auth.region || "global");
82
+ const sess = buildSessionFor({ ...blob, uid: auth.uid, name: auth.name, region });
83
+ // key/pick 带出:onError(key) 要冷却"刚用过的这个号";pick 让日志能写"这次为什么是它"
84
+ return { sess, region, key, pick: decision };
85
+ }
86
+ const region0 = region; // 构造参数优先
87
+
88
+ const chatSvc = createChatService({ id, fetchImpl, timeoutMs: connectTimeoutMs });
89
+ // models 服务按号选区:构造时无固定 region,listModels(sess, region) 动态传
90
+ const modelsSvc = createModelsService({ id, fetchImpl });
91
+
92
+ // 失败冷却:401/403/429/5xx 冷却当前号(cooldownMs,默认 30s),坏号不再参与轮换。
93
+ // 额度耗尽(quota)走长冷却(quotaCooldownMs,默认 1h)——额度按天重置,短冷却等于反复撞死号。
94
+ // 额度错在 provider 内直接换号重发(不依赖外层空转重试链路:那条链路在非流式路径不触发)。
95
+ // 收口只认「每个号都被上游明确判过额度」;排队/限流等非额度冷却不得谎称没额度
96
+ // Note: 判定收紧(全号才报 quota_exhausted,其余如实返回/报冷却中)— 见 .agents/notes/implemented/bug-fix/2026-09-28-qoder-quota-cooldown-switch.md
97
+ async function chat(body, opts) {
98
+ // scope=reqId:同一次客户端请求的多次上游调用(空转重试)粘同一个号
99
+ const scope = opts?.reqId ? `req:${opts.reqId}` : null;
100
+ const stripped = { ...body };
101
+ if (typeof stripped.model === "string" && stripped.model.startsWith("qoder/")) stripped.model = stripped.model.slice(6);
102
+ const badAuth = (n) => n === 401 || n === 403 || n === 429 || n >= 500;
103
+ const total = ring.keys.length;
104
+ // 换号上限:每次 continue 必然冷却掉一个号,故最多 total 轮;超出即退出(收敛保证)
105
+ let lastRes = null;
106
+ for (let hop = 0; hop <= total; hop++) {
107
+ const picked = pickSession(scope);
108
+ if (!picked) break;
109
+ const pickedKey = picked.key || "";
110
+ // 该号额度已耗尽且仍在冷却 → 不再打上游(打也是同一堵墙)。另有可用号时继续换,不轻易断言全灭。
111
+ if (exhaustedByQuota.has(pickedKey)) {
112
+ if (ring.isCooling(pickedKey)) {
113
+ if (ring.available() > 0) continue;
114
+ break;
115
+ }
116
+ exhaustedByQuota.delete(pickedKey);
117
+ }
118
+ let res;
119
+ try {
120
+ res = await chatSvc.runChat(stripped, picked.sess, picked.region, picked.pick);
121
+ } catch (e) {
122
+ try { ring.onError(pickedKey); } catch {}
123
+ throw e;
124
+ }
125
+ const st = res?.status ?? 0;
126
+ // 流式路径把上游非 200 整形成 200 + 流内 error(对外契约),真实状态码只能从回显头取;
127
+ // 否则坏号(401/403/429/5xx)永不冷却,粘号还会把重试继续粘在这个坏号上。
128
+ const ust = Number(res?.headers?.get?.("x-mslxdff-qoder-upstream-status")) || 0;
129
+ const isQuota = res?.headers?.get?.("x-mslxdff-qoder-quota") === "1";
130
+ const bad = badAuth(st) ? st : (badAuth(ust) ? ust : 0);
131
+ if (bad) {
132
+ try { ring.onError(pickedKey, isQuota ? quotaCooldownMs : cooldownMs); } catch {}
133
+ if (isQuota) exhaustedByQuota.add(pickedKey);
134
+ // 冷却是个决定:钉在响应上,管线写进模型日志(cooled=<status>)
135
+ try { res.headers?.set?.("x-mslxdff-qoder-cooldown", String(bad)); } catch {}
136
+ }
137
+ // 额度耗尽:本号已废(长冷却),若还有别的号就立刻换号重发,别把空流递给客户端
138
+ if (isQuota) {
139
+ lastRes = res;
140
+ if (ring.available() > 0) continue;
141
+ break;
142
+ }
143
+ return res;
144
+ }
145
+ // 收口按「真实全景」分档,不得把「非额度冷却」误报成没额度:
146
+ // 仅当每一个号都被上游明确判过额度,才是真的全号额度耗尽。
147
+ if (total > 0 && ring.keys.every((k) => exhaustedByQuota.has(k))) return quotaExhaustedResponse();
148
+ if (lastRes) return lastRes; // 有上游响应 → 如实透出(含上游真错)
149
+ if (!ring.available()) return allCoolingResponse(); // 全在冷却但非额度 → 冷却中提示
150
+ return new Response(JSON.stringify({ error: { message: "qoder: 无可用账号 — 先跑 mslxdff -provider qoder login", type: "auth_error" } }), { status: 401, headers: { "Content-Type": "application/json" } });
151
+ }
152
+
153
+ // 额度耗尽提示(每一个号都被上游明确判过额度):继续等只会更糟,让调用方立刻知道要换号/等明天
154
+ function quotaExhaustedResponse() {
155
+ return new Response(JSON.stringify({
156
+ error: { message: "qoder: 所有账号额度已用完(Billing daily count exceeded),请更换账号或明日再试", type: "quota_exhausted" },
157
+ }), { status: 429, headers: { "Content-Type": "application/json", "x-mslxdff-qoder-quota-exhausted": "1" } });
158
+ }
159
+
160
+ // 冷却中(非额度原因):如实说"暂不可用",不得声称额度耗尽
161
+ function allCoolingResponse() {
162
+ return new Response(JSON.stringify({
163
+ error: { message: "qoder: 账号暂不可用(上游限流/排队冷却中),请稍后重试", type: "all_cooling" },
164
+ }), { status: 429, headers: { "Content-Type": "application/json", "x-mslxdff-qoder-all-cooling": "1" } });
165
+ }
166
+ // 全号聚合:双号分属 cn/global 两区,模型表各不同(cn 14 个/global 15 个);
167
+ // 只取单号会漏另一区(如轮询到 global 就看不到 cn 独有的 q37fmodel/gm51model)。
168
+ // 按 id 并集去重,只返回 enable=true 的可调用模型(过滤已下沉到 models.js)。
169
+ function eachCred() {
170
+ const out = [];
171
+ const seen = new Set();
172
+ const push = (blob, auth) => {
173
+ if (!blob?.deviceToken || seen.has(blob.deviceToken)) return;
174
+ seen.add(blob.deviceToken);
175
+ out.push({ blob, auth: auth || {} });
176
+ };
177
+ for (const k of keys) {
178
+ const blob = accountFromBlob(k);
179
+ if (!blob?.deviceToken) continue;
180
+ const auth = authList.find((a) => String(a?.refreshToken || "") === String(blob.refreshToken || "")) || authList[0] || {};
181
+ push(blob, auth);
182
+ }
183
+ if (!out.length) {
184
+ // keys 为空但 auth 目录有号(如 state 被外部改写):从落盘 doc 合成凭据,目录不断即可出列表
185
+ try {
186
+ for (const { uid, doc } of listAccountDocs()) {
187
+ const a = doc?.auth || {};
188
+ if (!a.deviceToken) continue;
189
+ push({ deviceToken: a.deviceToken, refreshToken: a.refreshToken || "" },
190
+ { uid, name: doc?.account?.name || "", region: a.region || "global", refreshToken: a.refreshToken || "" });
191
+ }
192
+ } catch {}
193
+ }
194
+ return out;
195
+ }
196
+
197
+ async function listModels() {
198
+ const creds = eachCred();
199
+ if (!creds.length) return [];
200
+ const seen = new Set();
201
+ const out = [];
202
+ for (const { blob, auth } of creds) {
203
+ const region = normalizeRegion(region0 || auth.region || "global");
204
+ const sess = buildSessionFor({ ...blob, uid: auth.uid, name: auth.name, region });
205
+ try {
206
+ const list = await modelsSvc.listModels(sess, region);
207
+ for (const m of list) {
208
+ if (!m?.id || seen.has(m.id)) continue;
209
+ seen.add(m.id);
210
+ out.push(m);
211
+ }
212
+ } catch {}
213
+ }
214
+ return out;
215
+ }
216
+
217
+ async function preheat() {
218
+ try {
219
+ const list = await listModels();
220
+ return list.length ? { ok: true } : { ok: false, error: "no account" };
221
+ } catch (e) { return { ok: false, error: String(e?.message || e).slice(0, 120) }; }
222
+ }
223
+
224
+ async function close() {}
225
+ async function chatWithKeys(body, keysOverride, opts) {
226
+ const tmp = createQoderProvider({ id, apiKeys: keysOverride, file, fetchImpl, cooldownMs, connectTimeoutMs });
227
+ return tmp.chat(body, opts);
228
+ }
229
+
230
+ return { id, chat, chatWithKeys, listModels, preheat, close, keyRing: ring, baseUrl: "qoder://native", region: normalizeRegion(region || authList[0]?.region) };
185
231
  }
@@ -1,46 +1,103 @@
1
- // qoder 上游 SSE 信封解析(转译 qoder2api bridge.go ExtractDelta)
2
- // 帧形态:data:{"headers":{},"body":"<内层JSON>","statusCodeValue":200}
3
- // 信封 statusCodeValue!=200 = 瞬时上游错;内层 code!=0 = 业务错;usage 同帧合并
4
- export function extractDelta(dataLine) {
5
- let wrapper;
6
- try { wrapper = JSON.parse(dataLine); } catch { return {}; }
7
- if (wrapper.statusCodeValue !== undefined && Number(wrapper.statusCodeValue) !== 200) {
8
- const detail = typeof wrapper.body === "string" && wrapper.body ? wrapper.body : JSON.stringify(wrapper).slice(0, 400);
9
- return { err: { kind: "upstream", status: Number(wrapper.statusCodeValue) || 502, detail } };
10
- }
11
- const inner = wrapper.body;
12
- if (!inner || typeof inner !== "string") return {};
13
- let j;
14
- try { j = JSON.parse(inner); } catch { return {}; }
15
- let usageIn = 0, usageOut = 0;
16
- if (j.usage && typeof j.usage === "object") {
17
- usageIn = Math.trunc(Number(j.usage.prompt_tokens) || 0);
18
- usageOut = Math.trunc(Number(j.usage.completion_tokens) || 0);
19
- }
20
- const choices = Array.isArray(j.choices) ? j.choices : [];
21
- for (const ch of choices) {
22
- const delta = ch?.delta;
23
- if (!delta) continue;
24
- const role = typeof delta.role === "string" ? delta.role : "";
25
- const content = typeof delta.content === "string" ? delta.content : "";
26
- const reasoning = typeof delta.reasoning_content === "string" ? delta.reasoning_content : "";
27
- const toolCalls = Array.isArray(delta.tool_calls) && delta.tool_calls.length ? delta.tool_calls : null;
28
- if (role || content || reasoning || toolCalls) {
29
- return { role, content, reasoning, toolCalls, usageIn, usageOut };
30
- }
31
- }
32
- if (typeof j.code === "string" && j.code && j.code !== "0") {
33
- return { err: { kind: /inappropriate|DataInspection|Sensitive|ContentFilter/i.test(String(j.message)) ? "content_policy" : "business", code: j.code, detail: String(j.message || "") } };
34
- }
35
- if (usageIn > 0 || usageOut > 0) return { usageIn, usageOut };
36
- return {};
37
- }
38
-
39
- // 错误四分类 → HTTP 状态(对齐 errors.go ErrorStatus 语义)
40
- export function errorStatus(err) {
41
- if (!err) return 500;
42
- if (err.kind === "content_policy") return 400;
43
- if (err.kind === "auth") return [401, 403].includes(err.status) ? err.status : 401;
44
- if (err.kind === "upstream") return [401, 403, 429].includes(err.status) ? err.status : 502;
45
- return 502;
46
- }
1
+ // qoder 上游 SSE 信封解析(转译 qoder2api bridge.go ExtractDelta)
2
+ // 帧形态:data:{"headers":{},"body":"<内层JSON>","statusCodeValue":200}
3
+ // 信封 statusCodeValue!=200 = 瞬时上游错;内层 code!=0 = 业务错;usage 同帧合并
4
+ // 排障日志(QODER_DEBUG_STREAM=1 开):静默吞帧的分支必须有痕,否则"内容凭空消失"无从追
5
+ const DBG_SSE = () => process.env.QODER_DEBUG_STREAM === "1";
6
+ const ddbg = (...a) => { if (DBG_SSE()) console.log("[qoder-sse]", ...a); };
7
+
8
+ export function extractDelta(dataLine) {
9
+ let wrapper;
10
+ try { wrapper = JSON.parse(dataLine); } catch (e) {
11
+ ddbg(`[envelope-parse-fail] ${e.message} line=${dataLine.slice(0, 300)}`);
12
+ return {};
13
+ }
14
+ if (wrapper.statusCodeValue !== undefined && Number(wrapper.statusCodeValue) !== 200) {
15
+ const detail = typeof wrapper.body === "string" && wrapper.body ? wrapper.body : JSON.stringify(wrapper).slice(0, 400);
16
+ // 额度耗尽可能裹在非 200 信封里,且实测是**多层嵌套**(外层 statusCodeValue:403 →
17
+ // code:10605 → 内层 code:"110" / "Billing daily count exceeded")。只看一层会把额度错
18
+ // 当普通 403;显式扫各层信号才能可靠命中(排队 10605 无 110 信号,天然不误判)。
19
+ const quota = findQuotaSignal(wrapper);
20
+ if (quota) return { err: { kind: "quota", status: 429, detail: quota.slice(0, 300) } };
21
+ return { err: { kind: "upstream", status: Number(wrapper.statusCodeValue) || 502, detail } };
22
+ }
23
+ const inner = wrapper.body;
24
+ if (!inner || typeof inner !== "string") {
25
+ ddbg(`[body-not-string] bodyType=${typeof inner} envelopeKeys=${Object.keys(wrapper).join(",")} envelope=${dataLine.slice(0, 300)}`);
26
+ return {};
27
+ }
28
+ let j;
29
+ try { j = JSON.parse(inner); } catch (e) {
30
+ ddbg(`[inner-parse-fail] ${e.message} body=${inner.slice(0, 300)}`);
31
+ return {};
32
+ }
33
+ let usageIn = 0, usageOut = 0;
34
+ if (j.usage && typeof j.usage === "object") {
35
+ usageIn = Math.trunc(Number(j.usage.prompt_tokens) || 0);
36
+ usageOut = Math.trunc(Number(j.usage.completion_tokens) || 0);
37
+ }
38
+ const choices = Array.isArray(j.choices) ? j.choices : [];
39
+ for (const ch of choices) {
40
+ const delta = ch?.delta;
41
+ if (!delta) continue;
42
+ const role = typeof delta.role === "string" ? delta.role : "";
43
+ const content = typeof delta.content === "string" ? delta.content : "";
44
+ const reasoning = typeof delta.reasoning_content === "string" ? delta.reasoning_content : "";
45
+ const toolCalls = Array.isArray(delta.tool_calls) && delta.tool_calls.length ? delta.tool_calls : null;
46
+ if (role || content || reasoning || toolCalls) {
47
+ return { role, content, reasoning, toolCalls, usageIn, usageOut };
48
+ }
49
+ }
50
+ if (typeof j.code === "string" && j.code && j.code !== "0") {
51
+ const msg = String(j.message || "");
52
+ // 额度耗尽(实测 code=110 "Billing daily count exceeded"):按配额错单列,门面据此换号。
53
+ // 只认 code 110 / Billing 专属措辞 —— 普通 "quota exceeded"(code 115)仍归业务错,不误伤。
54
+ if (isQuotaError(j.code, msg)) return { err: { kind: "quota", status: 429, code: j.code, detail: msg || `code=${j.code}` } };
55
+ return { err: { kind: /inappropriate|DataInspection|Sensitive|ContentFilter/i.test(msg) ? "content_policy" : "business", code: j.code, detail: msg } };
56
+ }
57
+ if (usageIn > 0 || usageOut > 0) return { usageIn, usageOut };
58
+ // 走到这里 = 帧既无 content/tool_calls、也无业务错、也无 usage。空轮排查的关键取证点:
59
+ // 把内层 JSON 的顶层键与 choices 原样打出来,一眼看出上游改了什么字段名。
60
+ ddbg(`[no-delta] innerKeys=${Object.keys(j).join(",")} choicesLen=${choices.length} inner=${JSON.stringify(j).slice(0, 400)}`);
61
+ return {};
62
+ }
63
+
64
+ /** 额度耗尽识别:code 110 白名单 + Billing 专属措辞(收紧:普通 quota exceeded 归业务错) */
65
+ export function isQuotaError(code, message) {
66
+ if (String(code || "").trim() === "110") return true;
67
+ return /billing\s*(daily)?\s*(count|limit|quota)?\s*exceed/i.test(String(message || ""));
68
+ }
69
+
70
+ // 显式额度信号扫描:信封各层里找 code 110 / Billing 明确措辞。
71
+ // 限深 4 层(实测 3 层足够,留一层余量),字符串层自动尝试 JSON.parse 后继续下钻。
72
+ // 排队 10605(isQueued/serviceAvailable)无 110 信号 → 返回 null,天然不误判为额度。
73
+ const MAX_ENVELOPE_DEPTH = 4;
74
+ export function findQuotaSignal(value, depth = 0) {
75
+ if (value == null || depth > MAX_ENVELOPE_DEPTH) return null;
76
+ if (typeof value === "string") {
77
+ const s = value.trim();
78
+ if (!s) return null;
79
+ if (isQuotaError("", s)) return s;
80
+ try {
81
+ return findQuotaSignal(JSON.parse(s), depth + 1);
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+ if (typeof value !== "object") return null;
87
+ if (isQuotaError(value.code, value.message)) return String(value.message || value.code || "");
88
+ for (const v of Object.values(value)) {
89
+ const hit = findQuotaSignal(v, depth + 1);
90
+ if (hit) return hit;
91
+ }
92
+ return null;
93
+ }
94
+
95
+ // 错误四分类 → HTTP 状态(对齐 errors.go ErrorStatus 语义)
96
+ export function errorStatus(err) {
97
+ if (!err) return 500;
98
+ if (err.kind === "content_policy") return 400;
99
+ if (err.kind === "auth") return [401, 403].includes(err.status) ? err.status : 401;
100
+ if (err.kind === "upstream") return [401, 403, 429].includes(err.status) ? err.status : 502;
101
+ if (err.kind === "quota") return 429; // 额度错按限流语义出(门面据此换号/冷却)
102
+ return 502;
103
+ }
@@ -3,6 +3,7 @@
3
3
  // 背景:keyring 每次 next() 都前进一格,而 qoder 两个号分属 cn/global 两区(region 决定 URL),
4
4
  // 于是「重试」会把同一个请求甩到另一个区——排障时表现为"URL 莫名在切"。粘号把"切号"收回到
5
5
  // 唯一正当理由:该号已被冷却。无 scope(拿不到请求标识)时退化为原 round-robin,与旧行为一致。
6
+ // Note: 切号的唯一正当理由是冷却(401/403/429/5xx)——空转等其它失败不换号,故选择器要能问 keyring "上次这个号还在冷却吗" — 见 .agents/notes/implemented/feature/2026-09-27-qoder-per-request-sticky-account.md
6
7
  export function createStickyPicker({ pick, isCooling = () => false, now = Date.now, ttlMs = 600_000, maxEntries = 200 } = {}) {
7
8
  const seen = new Map(); // scope → { key, at }
8
9