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,337 +1,337 @@
|
|
|
1
|
-
// qwenwork provider 门面:多号 keyring 轮转 + 401/403 refresh 重试 + 额度长冷却换号。
|
|
2
|
-
// 协议细节全部下沉到 cosy/upstream/payload/sse/stream;这里只做编排(对齐 qoder/index.js 形状)。
|
|
3
|
-
// Note: 为何独立 provider 而非 qoder 的新 region — 见 docs/adr/0037-qwenwork-independent-provider.md
|
|
4
|
-
import { loadProviderKeys, loadProviderConfig } from "../../state.js";
|
|
5
|
-
import { listAccountDocs, accountFromBlob, updateQwenworkTokens } from "./account-store.js";
|
|
6
|
-
import { createKeyRing } from "../keyring.js";
|
|
7
|
-
import { envInt } from "../base.js";
|
|
8
|
-
import { compatFetch, timeoutSignal } from "../../compat.js";
|
|
9
|
-
import { joinModelId } from "../model-id.js";
|
|
10
|
-
import { buildBody, mapModel } from "./payload.js";
|
|
11
|
-
import { chatRequest, modelList, refreshDeviceToken, expiryUnix, userInfo } from "./upstream.js";
|
|
12
|
-
import { invalidateSession } from "./cosy.js";
|
|
13
|
-
import { collectSync, isCreditsExhausted, creditsExhaustedText } from "./sse.js";
|
|
14
|
-
import { transformStream } from "./stream.js";
|
|
15
|
-
import { KNOWN_MODELS, MODELS_CACHE_MS, CHAT_URL } from "./constants.js";
|
|
16
|
-
|
|
17
|
-
function hostOf(url) {
|
|
18
|
-
try { return new URL(String(url)).host; } catch { return "-"; }
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function errRes(status, msg, type = "upstream_error", extra = {}) {
|
|
22
|
-
return new Response(JSON.stringify({ error: { message: msg, type } }), {
|
|
23
|
-
status,
|
|
24
|
-
headers: { "Content-Type": "application/json", ...extra },
|
|
25
|
-
});
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function createQwenworkProvider({
|
|
29
|
-
id = "qwenwork",
|
|
30
|
-
apiKeys,
|
|
31
|
-
file,
|
|
32
|
-
fetchImpl,
|
|
33
|
-
cooldownMs = envInt("MSLXDFF_QWENWORK_COOLDOWN_MS", 30_000),
|
|
34
|
-
quotaCooldownMs = envInt("MSLXDFF_QWENWORK_QUOTA_COOLDOWN_MS", 3600_000),
|
|
35
|
-
connectTimeoutMs = Number(process.env.MSLXDFF_QWENWORK_TIMEOUT_MS) || 120_000,
|
|
36
|
-
} = {}) {
|
|
37
|
-
// 单测隔离(MSLXDFF_TEST=1 / NODE_ENV=test / --test 进程):state 与 auth 目录走测试替身,
|
|
38
|
-
// 与你个人真实账号完全隔离(防止单测把真号读进来、把真 refreshToken 写回去)。
|
|
39
|
-
// 实测教训:构造传了 `apiKeys: []`,门面却从 `~/.config/mslxdff/state.json` 读出真号 → 无号测试打了现网。
|
|
40
|
-
const testIsolated = process.env.MSLXDFF_TEST === "1" || process.env.NODE_ENV === "test" || String(process.execArgv || []).includes("--test") || process.argv.slice(1).some((a) => String(a).includes("--test"));
|
|
41
|
-
const keys = (() => {
|
|
42
|
-
if (testIsolated && file === undefined && apiKeys !== undefined) {
|
|
43
|
-
return [...new Set((Array.isArray(apiKeys) ? apiKeys : []).map((k) => String(k).trim()).filter(Boolean))];
|
|
44
|
-
}
|
|
45
|
-
if (Array.isArray(apiKeys) && apiKeys.length) return [...new Set(apiKeys.map((k) => String(k).trim()).filter(Boolean))];
|
|
46
|
-
try { return loadProviderKeys(id, file ? { file } : {}); } catch { return []; }
|
|
47
|
-
})();
|
|
48
|
-
let authList = [];
|
|
49
|
-
if (!testIsolated) {
|
|
50
|
-
try { authList = loadProviderConfig(id, file ? { file } : {})?.auths || []; } catch {}
|
|
51
|
-
try {
|
|
52
|
-
const docs = listAccountDocs();
|
|
53
|
-
for (const d of docs) {
|
|
54
|
-
const row = authList.find((a) => String(a?.uid) === String(d.uid));
|
|
55
|
-
if (!row) authList.push({ uid: d.uid, refreshToken: d.doc?.auth?.refreshToken || "", name: d.doc?.account?.name || "" });
|
|
56
|
-
}
|
|
57
|
-
} catch {}
|
|
58
|
-
}
|
|
59
|
-
const ring = createKeyRing(keys, { cooldownMs });
|
|
60
|
-
const exhaustedByQuota = new Set();
|
|
61
|
-
if (!fetchImpl) fetchImpl = compatFetch;
|
|
62
|
-
const cdbg = (...a) => { if (process.env.QWENWORK_DEBUG === "1") console.log("[qwenwork-chat]", ...a); };
|
|
63
|
-
|
|
64
|
-
// blob → 上游 account(含 uid/nickname/email,供 COSY 身份 payload 用)
|
|
65
|
-
function accountFor(key) {
|
|
66
|
-
const blob = accountFromBlob(key);
|
|
67
|
-
if (!blob?.accessToken) return null;
|
|
68
|
-
const auth = authList.find((a) => String(a?.refreshToken || "") === String(blob.refreshToken || "")) || {};
|
|
69
|
-
let doc = null;
|
|
70
|
-
try {
|
|
71
|
-
const docs = listAccountDocs();
|
|
72
|
-
doc = docs.find((d) => String(d.uid) === String(auth.uid)) || docs.find((d) => d.doc?.auth?.refreshToken === blob.refreshToken) || null;
|
|
73
|
-
} catch {}
|
|
74
|
-
return {
|
|
75
|
-
id: auth.uid || doc?.uid || "",
|
|
76
|
-
uid: auth.uid || doc?.uid || "",
|
|
77
|
-
nickname: auth.name || doc?.doc?.account?.name || "",
|
|
78
|
-
email: doc?.doc?.account?.email || "",
|
|
79
|
-
accessToken: blob.accessToken,
|
|
80
|
-
refreshToken: blob.refreshToken || auth.refreshToken || doc?.doc?.auth?.refreshToken || "",
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function withEcho(res, account, upstreamStatus) {
|
|
85
|
-
try {
|
|
86
|
-
res.headers.set("x-mslxdff-upstream", hostOf(CHAT_URL));
|
|
87
|
-
if (account?.uid) res.headers.set("x-mslxdff-qwenwork-account", String(account.uid).slice(-8));
|
|
88
|
-
if (upstreamStatus) res.headers.set("x-mslxdff-qwenwork-upstream-status", String(upstreamStatus));
|
|
89
|
-
} catch {}
|
|
90
|
-
return res;
|
|
91
|
-
}
|
|
92
|
-
function withQuota(res) {
|
|
93
|
-
try { res.headers.set("x-mslxdff-qwenwork-quota", "1"); } catch {}
|
|
94
|
-
return res;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
async function tryRefresh(account) {
|
|
98
|
-
if (!account?.refreshToken) return false;
|
|
99
|
-
try {
|
|
100
|
-
const data = await refreshDeviceToken(account.refreshToken, fetchImpl);
|
|
101
|
-
const access = data.token || data.device_token;
|
|
102
|
-
const refresh = data.refresh_token || account.refreshToken;
|
|
103
|
-
const expiresAt = expiryUnix(data);
|
|
104
|
-
// 留底旧值:下面两行会就地覆盖 account 的 token,而同步 ring blob 必须按**旧**值去环里找。
|
|
105
|
-
const prevAccess = account.accessToken || "";
|
|
106
|
-
const prevRefresh = account.refreshToken || "";
|
|
107
|
-
account.accessToken = access;
|
|
108
|
-
account.refreshToken = refresh;
|
|
109
|
-
try { await updateQwenworkTokens({ uid: account.uid, accessToken: access, refreshToken: refresh, expiresAt, file }); }
|
|
110
|
-
catch { invalidateSession(account); }
|
|
111
|
-
// 同步 ring 里的 blob(token 轮换后旧 blob 即失效)。
|
|
112
|
-
// ⚠ 必须拿**刷新前**的值去找旧 blob:旧写法拿的是 `data.refresh_token`(刷新后的新值)与
|
|
113
|
-
// `account.accessToken`(已在上面被赋成新值),与环里的旧 blob 结构性永不相等
|
|
114
|
-
// ⇒ 两个 find 恒返回 undefined ⇒ `if (cur) … else if (oldKey) …` 恒落空
|
|
115
|
-
// ⇒ 环里永远留旧 blob,下次 next() 取出的还是过期 token。
|
|
116
|
-
try {
|
|
117
|
-
const newBlob = JSON.stringify({ device_token: access, refresh_token: refresh });
|
|
118
|
-
const cur = prevAccess ? ring.keys.find((k) => accountFromBlob(k)?.accessToken === prevAccess) : null;
|
|
119
|
-
const oldKey = prevRefresh
|
|
120
|
-
? ring.keys.find((k) => { const b = accountFromBlob(k); return b && b.refreshToken === prevRefresh; })
|
|
121
|
-
: null;
|
|
122
|
-
if (cur) ring.replace(cur, newBlob);
|
|
123
|
-
else if (oldKey) ring.replace(oldKey, newBlob);
|
|
124
|
-
else if (ring.keys.length === 1) ring.replace(ring.keys[0], newBlob); // 单号:无从比对也要换,否则旧 blob 永久留环
|
|
125
|
-
} catch {}
|
|
126
|
-
return true;
|
|
127
|
-
} catch (e) {
|
|
128
|
-
cdbg(`[refresh-fail] uid=${String(account.uid).slice(-8)} ${String(e?.message || e).slice(0, 160)}`);
|
|
129
|
-
return false;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
async function chat(body, opts) {
|
|
134
|
-
const stream = body?.stream !== false;
|
|
135
|
-
const wantModel = typeof body?.model === "string" && body.model.startsWith("qwenwork/")
|
|
136
|
-
? body.model.slice("qwenwork/".length)
|
|
137
|
-
: (body?.model || "flash");
|
|
138
|
-
const modelKey = mapModel(wantModel);
|
|
139
|
-
const stripped = { ...body, model: wantModel };
|
|
140
|
-
const total = ring.keys.length;
|
|
141
|
-
// 构造参数无号但 auth 目录有号(如测试注入单测环境或 state 被外部改写):不视为"无号",
|
|
142
|
-
// 直接按 401 引导 login——调用方先 login 落盘再 chat,这与各 provider 门面语义一致
|
|
143
|
-
if (!total) {
|
|
144
|
-
return errRes(401, "qwenwork: 无可用账号 — 先跑 mslxdff -provider qwenwork login", "auth_error");
|
|
145
|
-
}
|
|
146
|
-
let lastRes = null;
|
|
147
|
-
const refreshed = new Set();
|
|
148
|
-
for (let hop = 0; hop <= total; hop++) {
|
|
149
|
-
const key = ring.next();
|
|
150
|
-
if (!key) break;
|
|
151
|
-
if (exhaustedByQuota.has(key)) {
|
|
152
|
-
if (ring.isCooling(key)) {
|
|
153
|
-
if (ring.available() > 0) continue;
|
|
154
|
-
break;
|
|
155
|
-
}
|
|
156
|
-
exhaustedByQuota.delete(key);
|
|
157
|
-
}
|
|
158
|
-
let account = accountFor(key);
|
|
159
|
-
if (!account) { try { ring.onError(key); } catch {} continue; }
|
|
160
|
-
// 身份缺失(手动配 key、未走 login)时补一次 userinfo:COSY 身份 payload 的 uid/name/email 为空
|
|
161
|
-
// 会被上游判 `code 101 Signature invalid`(实测:同 token 空身份 403、有身份 200)。补上后才签名。
|
|
162
|
-
if (!account.uid) {
|
|
163
|
-
try {
|
|
164
|
-
const info = await userInfo(account.accessToken, fetchImpl);
|
|
165
|
-
if (info?.id) account.uid = String(info.id);
|
|
166
|
-
if (info?.name) account.nickname = String(info.name);
|
|
167
|
-
if (info?.email) account.email = String(info.email);
|
|
168
|
-
if (account.uid) {
|
|
169
|
-
try {
|
|
170
|
-
const { saveQwenworkAccount } = await import("./account-store.js");
|
|
171
|
-
await saveQwenworkAccount({ uid: account.uid, accessToken: account.accessToken, refreshToken: account.refreshToken || "", name: account.nickname || "", email: account.email || "", file });
|
|
172
|
-
} catch {}
|
|
173
|
-
}
|
|
174
|
-
} catch (e) {
|
|
175
|
-
cdbg(`[identity-enrich-fail] ${String(e?.message || e).slice(0, 160)}`);
|
|
176
|
-
}
|
|
177
|
-
if (!account.uid) { try { ring.onError(key); } catch {} lastRes = withEcho(errRes(401, "qwenwork: 账号身份缺失且 userinfo 失败,该 key 不可用", "auth_error"), account, 401); continue; }
|
|
178
|
-
}
|
|
179
|
-
const bodyStr = buildBody(stripped, modelKey);
|
|
180
|
-
let upRes;
|
|
181
|
-
try {
|
|
182
|
-
upRes = await chatRequest(account, modelKey, bodyStr, timeoutSignal(connectTimeoutMs), fetchImpl);
|
|
183
|
-
} catch (e) {
|
|
184
|
-
try { ring.onError(key); } catch {}
|
|
185
|
-
lastRes = withEcho(errRes(502, `qwenwork: 上游连接失败 ${String(e?.message || e).slice(0, 200)}`), account, 502);
|
|
186
|
-
continue;
|
|
187
|
-
}
|
|
188
|
-
if (!upRes.ok) {
|
|
189
|
-
const text = await upRes.text().catch(() => "");
|
|
190
|
-
const quota = isCreditsExhausted(upRes.status, text);
|
|
191
|
-
if (quota) {
|
|
192
|
-
try { ring.onError(key, quotaCooldownMs); } catch {}
|
|
193
|
-
exhaustedByQuota.add(key);
|
|
194
|
-
lastRes = withQuota(withEcho(errRes(429, `qwenwork: 账号积分耗尽 ${text.slice(0, 200)}`, "quota_exhausted"), account, upRes.status));
|
|
195
|
-
if (ring.available() > 0) continue;
|
|
196
|
-
break;
|
|
197
|
-
}
|
|
198
|
-
if ((upRes.status === 401 || upRes.status === 403) && account.refreshToken && !refreshed.has(key)) {
|
|
199
|
-
refreshed.add(key);
|
|
200
|
-
const ok = await tryRefresh(account);
|
|
201
|
-
if (ok) { hop -= 1; continue; }
|
|
202
|
-
try { ring.onError(key); } catch {}
|
|
203
|
-
lastRes = withEcho(errRes(upRes.status, `qwenwork: 授权失效 ${text.slice(0, 200)}`, "auth_error"), account, upRes.status);
|
|
204
|
-
continue;
|
|
205
|
-
}
|
|
206
|
-
try { ring.onError(key); } catch {}
|
|
207
|
-
lastRes = withEcho(errRes(upRes.status >= 500 ? 502 : upRes.status, `qwenwork: 上游 ${upRes.status} ${text.slice(0, 200)}`), account, upRes.status);
|
|
208
|
-
if (ring.available() > 0 && (upRes.status === 429 || upRes.status >= 500)) continue;
|
|
209
|
-
return lastRes;
|
|
210
|
-
}
|
|
211
|
-
// 200:流内仍可能裹着业务错(信封 statusCodeValue / code),非流式聚合时由 collectSync 判决;
|
|
212
|
-
// 流式路径照原作者 transform 透传(错误帧由 sse.handleLine 转成流内 error 事件)。
|
|
213
|
-
if (stream) {
|
|
214
|
-
const out = new Response(transformStream(upRes.body, wantModel, () => {}), {
|
|
215
|
-
status: 200,
|
|
216
|
-
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" },
|
|
217
|
-
});
|
|
218
|
-
return withEcho(out, account, 0);
|
|
219
|
-
}
|
|
220
|
-
const result = await collectSync(upRes.body, wantModel);
|
|
221
|
-
if (result.error) {
|
|
222
|
-
if (creditsExhaustedText(result.error)) {
|
|
223
|
-
try { ring.onError(key, quotaCooldownMs); } catch {}
|
|
224
|
-
exhaustedByQuota.add(key);
|
|
225
|
-
lastRes = withQuota(withEcho(errRes(429, `qwenwork: 账号积分耗尽 ${result.error}`, "quota_exhausted"), account, 429));
|
|
226
|
-
if (ring.available() > 0) continue;
|
|
227
|
-
break;
|
|
228
|
-
}
|
|
229
|
-
try { ring.onError(key); } catch {}
|
|
230
|
-
lastRes = withEcho(errRes(502, `qwenwork: 上游流内错误 ${result.error}`), account, 502);
|
|
231
|
-
continue;
|
|
232
|
-
}
|
|
233
|
-
const completion = result.completion;
|
|
234
|
-
completion.model = wantModel;
|
|
235
|
-
return withEcho(new Response(JSON.stringify(completion), { status: 200, headers: { "Content-Type": "application/json" } }), account, 0);
|
|
236
|
-
}
|
|
237
|
-
if (total > 0 && ring.keys.every((k) => exhaustedByQuota.has(k))) {
|
|
238
|
-
return errRes(429, "qwenwork: 所有账号积分已用完,请更换账号或等待额度恢复", "quota_exhausted", { "x-mslxdff-qwenwork-quota-exhausted": "1" });
|
|
239
|
-
}
|
|
240
|
-
if (lastRes) return lastRes;
|
|
241
|
-
if (!ring.available()) {
|
|
242
|
-
return errRes(429, "qwenwork: 账号暂不可用(上游限流/排队冷却中),请稍后重试", "all_cooling", { "x-mslxdff-qwenwork-all-cooling": "1" });
|
|
243
|
-
}
|
|
244
|
-
return errRes(401, "qwenwork: 无可用账号 — 先跑 mslxdff -provider qwenwork login", "auth_error");
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// 全号聚合:逐号拉 qwork 切片求并集(单号 3 个,多号互相印证)
|
|
248
|
-
let cache = null;
|
|
249
|
-
let fetchedAt = 0;
|
|
250
|
-
function eachAccount() {
|
|
251
|
-
const out = [];
|
|
252
|
-
const seen = new Set();
|
|
253
|
-
const push = (key, auth) => {
|
|
254
|
-
const account = accountFor(key);
|
|
255
|
-
if (!account?.accessToken || seen.has(account.accessToken)) return;
|
|
256
|
-
seen.add(account.accessToken);
|
|
257
|
-
out.push(account);
|
|
258
|
-
};
|
|
259
|
-
for (const k of keys) {
|
|
260
|
-
const blob = accountFromBlob(k);
|
|
261
|
-
if (!blob?.accessToken) continue;
|
|
262
|
-
const auth = authList.find((a) => String(a?.refreshToken || "") === String(blob.refreshToken || "")) || {};
|
|
263
|
-
push(k, auth);
|
|
264
|
-
}
|
|
265
|
-
if (!out.length) {
|
|
266
|
-
try {
|
|
267
|
-
for (const { uid, doc } of listAccountDocs()) {
|
|
268
|
-
const a = doc?.auth || {};
|
|
269
|
-
if (!a.accessToken) continue;
|
|
270
|
-
const account = { id: uid, uid, nickname: doc?.account?.name || "", email: doc?.account?.email || "", accessToken: a.accessToken, refreshToken: a.refreshToken || "" };
|
|
271
|
-
if (seen.has(account.accessToken)) continue;
|
|
272
|
-
seen.add(account.accessToken);
|
|
273
|
-
out.push(account);
|
|
274
|
-
}
|
|
275
|
-
} catch {}
|
|
276
|
-
}
|
|
277
|
-
return out;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
async function listModels() {
|
|
281
|
-
const now = Date.now();
|
|
282
|
-
if (cache && now - fetchedAt < MODELS_CACHE_MS) return cache;
|
|
283
|
-
const accounts = eachAccount();
|
|
284
|
-
if (!accounts.length) return [];
|
|
285
|
-
const seen = new Set();
|
|
286
|
-
const out = [];
|
|
287
|
-
for (const account of accounts) {
|
|
288
|
-
try {
|
|
289
|
-
const list = await modelList(account, fetchImpl);
|
|
290
|
-
for (const m of list) {
|
|
291
|
-
if (!m?.key || seen.has(m.key)) continue;
|
|
292
|
-
seen.add(m.key);
|
|
293
|
-
out.push({
|
|
294
|
-
id: joinModelId(id, m.key),
|
|
295
|
-
object: "model",
|
|
296
|
-
created: 1753600000,
|
|
297
|
-
owned_by: "qwenwork",
|
|
298
|
-
name: m.name || m.key,
|
|
299
|
-
context_length: m.maxInputTokens || 0,
|
|
300
|
-
price_factor: m.priceFactor || 0,
|
|
301
|
-
is_reasoning: !!m.isReasoning,
|
|
302
|
-
});
|
|
303
|
-
}
|
|
304
|
-
} catch {}
|
|
305
|
-
}
|
|
306
|
-
if (out.length) { cache = out; fetchedAt = now; }
|
|
307
|
-
// 取数失败时用实测快照兜底(目录恒为 3 个,不会空)
|
|
308
|
-
if (!out.length && !cache) {
|
|
309
|
-
return KNOWN_MODELS.map((m) => ({
|
|
310
|
-
id: joinModelId(id, m.key),
|
|
311
|
-
object: "model",
|
|
312
|
-
created: 1753600000,
|
|
313
|
-
owned_by: "qwenwork",
|
|
314
|
-
name: m.name,
|
|
315
|
-
context_length: 1000000,
|
|
316
|
-
price_factor: m.priceFactor,
|
|
317
|
-
is_reasoning: true,
|
|
318
|
-
}));
|
|
319
|
-
}
|
|
320
|
-
return out.length ? out : (cache || []);
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
async function preheat() {
|
|
324
|
-
try {
|
|
325
|
-
const list = await listModels();
|
|
326
|
-
return list.length ? { ok: true } : { ok: false, error: "no account" };
|
|
327
|
-
} catch (e) { return { ok: false, error: String(e?.message || e).slice(0, 120) }; }
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
async function close() {}
|
|
331
|
-
async function chatWithKeys(body, keysOverride, opts) {
|
|
332
|
-
const tmp = createQwenworkProvider({ id, apiKeys: keysOverride, file, fetchImpl, cooldownMs, quotaCooldownMs, connectTimeoutMs });
|
|
333
|
-
return tmp.chat(body, opts);
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
return { id, chat, chatWithKeys, listModels, preheat, close, keyRing: ring, baseUrl: "qwenwork://native" };
|
|
337
|
-
}
|
|
1
|
+
// qwenwork provider 门面:多号 keyring 轮转 + 401/403 refresh 重试 + 额度长冷却换号。
|
|
2
|
+
// 协议细节全部下沉到 cosy/upstream/payload/sse/stream;这里只做编排(对齐 qoder/index.js 形状)。
|
|
3
|
+
// Note: 为何独立 provider 而非 qoder 的新 region — 见 docs/adr/0037-qwenwork-independent-provider.md
|
|
4
|
+
import { loadProviderKeys, loadProviderConfig } from "../../state.js";
|
|
5
|
+
import { listAccountDocs, accountFromBlob, updateQwenworkTokens } from "./account-store.js";
|
|
6
|
+
import { createKeyRing } from "../keyring.js";
|
|
7
|
+
import { envInt } from "../base.js";
|
|
8
|
+
import { compatFetch, timeoutSignal } from "../../compat.js";
|
|
9
|
+
import { joinModelId } from "../model-id.js";
|
|
10
|
+
import { buildBody, mapModel } from "./payload.js";
|
|
11
|
+
import { chatRequest, modelList, refreshDeviceToken, expiryUnix, userInfo } from "./upstream.js";
|
|
12
|
+
import { invalidateSession } from "./cosy.js";
|
|
13
|
+
import { collectSync, isCreditsExhausted, creditsExhaustedText } from "./sse.js";
|
|
14
|
+
import { transformStream } from "./stream.js";
|
|
15
|
+
import { KNOWN_MODELS, MODELS_CACHE_MS, CHAT_URL } from "./constants.js";
|
|
16
|
+
|
|
17
|
+
function hostOf(url) {
|
|
18
|
+
try { return new URL(String(url)).host; } catch { return "-"; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function errRes(status, msg, type = "upstream_error", extra = {}) {
|
|
22
|
+
return new Response(JSON.stringify({ error: { message: msg, type } }), {
|
|
23
|
+
status,
|
|
24
|
+
headers: { "Content-Type": "application/json", ...extra },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createQwenworkProvider({
|
|
29
|
+
id = "qwenwork",
|
|
30
|
+
apiKeys,
|
|
31
|
+
file,
|
|
32
|
+
fetchImpl,
|
|
33
|
+
cooldownMs = envInt("MSLXDFF_QWENWORK_COOLDOWN_MS", 30_000),
|
|
34
|
+
quotaCooldownMs = envInt("MSLXDFF_QWENWORK_QUOTA_COOLDOWN_MS", 3600_000),
|
|
35
|
+
connectTimeoutMs = Number(process.env.MSLXDFF_QWENWORK_TIMEOUT_MS) || 120_000,
|
|
36
|
+
} = {}) {
|
|
37
|
+
// 单测隔离(MSLXDFF_TEST=1 / NODE_ENV=test / --test 进程):state 与 auth 目录走测试替身,
|
|
38
|
+
// 与你个人真实账号完全隔离(防止单测把真号读进来、把真 refreshToken 写回去)。
|
|
39
|
+
// 实测教训:构造传了 `apiKeys: []`,门面却从 `~/.config/mslxdff/state.json` 读出真号 → 无号测试打了现网。
|
|
40
|
+
const testIsolated = process.env.MSLXDFF_TEST === "1" || process.env.NODE_ENV === "test" || String(process.execArgv || []).includes("--test") || process.argv.slice(1).some((a) => String(a).includes("--test"));
|
|
41
|
+
const keys = (() => {
|
|
42
|
+
if (testIsolated && file === undefined && apiKeys !== undefined) {
|
|
43
|
+
return [...new Set((Array.isArray(apiKeys) ? apiKeys : []).map((k) => String(k).trim()).filter(Boolean))];
|
|
44
|
+
}
|
|
45
|
+
if (Array.isArray(apiKeys) && apiKeys.length) return [...new Set(apiKeys.map((k) => String(k).trim()).filter(Boolean))];
|
|
46
|
+
try { return loadProviderKeys(id, file ? { file } : {}); } catch { return []; }
|
|
47
|
+
})();
|
|
48
|
+
let authList = [];
|
|
49
|
+
if (!testIsolated) {
|
|
50
|
+
try { authList = loadProviderConfig(id, file ? { file } : {})?.auths || []; } catch {}
|
|
51
|
+
try {
|
|
52
|
+
const docs = listAccountDocs();
|
|
53
|
+
for (const d of docs) {
|
|
54
|
+
const row = authList.find((a) => String(a?.uid) === String(d.uid));
|
|
55
|
+
if (!row) authList.push({ uid: d.uid, refreshToken: d.doc?.auth?.refreshToken || "", name: d.doc?.account?.name || "" });
|
|
56
|
+
}
|
|
57
|
+
} catch {}
|
|
58
|
+
}
|
|
59
|
+
const ring = createKeyRing(keys, { cooldownMs });
|
|
60
|
+
const exhaustedByQuota = new Set();
|
|
61
|
+
if (!fetchImpl) fetchImpl = compatFetch;
|
|
62
|
+
const cdbg = (...a) => { if (process.env.QWENWORK_DEBUG === "1") console.log("[qwenwork-chat]", ...a); };
|
|
63
|
+
|
|
64
|
+
// blob → 上游 account(含 uid/nickname/email,供 COSY 身份 payload 用)
|
|
65
|
+
function accountFor(key) {
|
|
66
|
+
const blob = accountFromBlob(key);
|
|
67
|
+
if (!blob?.accessToken) return null;
|
|
68
|
+
const auth = authList.find((a) => String(a?.refreshToken || "") === String(blob.refreshToken || "")) || {};
|
|
69
|
+
let doc = null;
|
|
70
|
+
try {
|
|
71
|
+
const docs = listAccountDocs();
|
|
72
|
+
doc = docs.find((d) => String(d.uid) === String(auth.uid)) || docs.find((d) => d.doc?.auth?.refreshToken === blob.refreshToken) || null;
|
|
73
|
+
} catch {}
|
|
74
|
+
return {
|
|
75
|
+
id: auth.uid || doc?.uid || "",
|
|
76
|
+
uid: auth.uid || doc?.uid || "",
|
|
77
|
+
nickname: auth.name || doc?.doc?.account?.name || "",
|
|
78
|
+
email: doc?.doc?.account?.email || "",
|
|
79
|
+
accessToken: blob.accessToken,
|
|
80
|
+
refreshToken: blob.refreshToken || auth.refreshToken || doc?.doc?.auth?.refreshToken || "",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function withEcho(res, account, upstreamStatus) {
|
|
85
|
+
try {
|
|
86
|
+
res.headers.set("x-mslxdff-upstream", hostOf(CHAT_URL));
|
|
87
|
+
if (account?.uid) res.headers.set("x-mslxdff-qwenwork-account", String(account.uid).slice(-8));
|
|
88
|
+
if (upstreamStatus) res.headers.set("x-mslxdff-qwenwork-upstream-status", String(upstreamStatus));
|
|
89
|
+
} catch {}
|
|
90
|
+
return res;
|
|
91
|
+
}
|
|
92
|
+
function withQuota(res) {
|
|
93
|
+
try { res.headers.set("x-mslxdff-qwenwork-quota", "1"); } catch {}
|
|
94
|
+
return res;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function tryRefresh(account) {
|
|
98
|
+
if (!account?.refreshToken) return false;
|
|
99
|
+
try {
|
|
100
|
+
const data = await refreshDeviceToken(account.refreshToken, fetchImpl);
|
|
101
|
+
const access = data.token || data.device_token;
|
|
102
|
+
const refresh = data.refresh_token || account.refreshToken;
|
|
103
|
+
const expiresAt = expiryUnix(data);
|
|
104
|
+
// 留底旧值:下面两行会就地覆盖 account 的 token,而同步 ring blob 必须按**旧**值去环里找。
|
|
105
|
+
const prevAccess = account.accessToken || "";
|
|
106
|
+
const prevRefresh = account.refreshToken || "";
|
|
107
|
+
account.accessToken = access;
|
|
108
|
+
account.refreshToken = refresh;
|
|
109
|
+
try { await updateQwenworkTokens({ uid: account.uid, accessToken: access, refreshToken: refresh, expiresAt, file }); }
|
|
110
|
+
catch { invalidateSession(account); }
|
|
111
|
+
// 同步 ring 里的 blob(token 轮换后旧 blob 即失效)。
|
|
112
|
+
// ⚠ 必须拿**刷新前**的值去找旧 blob:旧写法拿的是 `data.refresh_token`(刷新后的新值)与
|
|
113
|
+
// `account.accessToken`(已在上面被赋成新值),与环里的旧 blob 结构性永不相等
|
|
114
|
+
// ⇒ 两个 find 恒返回 undefined ⇒ `if (cur) … else if (oldKey) …` 恒落空
|
|
115
|
+
// ⇒ 环里永远留旧 blob,下次 next() 取出的还是过期 token。
|
|
116
|
+
try {
|
|
117
|
+
const newBlob = JSON.stringify({ device_token: access, refresh_token: refresh });
|
|
118
|
+
const cur = prevAccess ? ring.keys.find((k) => accountFromBlob(k)?.accessToken === prevAccess) : null;
|
|
119
|
+
const oldKey = prevRefresh
|
|
120
|
+
? ring.keys.find((k) => { const b = accountFromBlob(k); return b && b.refreshToken === prevRefresh; })
|
|
121
|
+
: null;
|
|
122
|
+
if (cur) ring.replace(cur, newBlob);
|
|
123
|
+
else if (oldKey) ring.replace(oldKey, newBlob);
|
|
124
|
+
else if (ring.keys.length === 1) ring.replace(ring.keys[0], newBlob); // 单号:无从比对也要换,否则旧 blob 永久留环
|
|
125
|
+
} catch {}
|
|
126
|
+
return true;
|
|
127
|
+
} catch (e) {
|
|
128
|
+
cdbg(`[refresh-fail] uid=${String(account.uid).slice(-8)} ${String(e?.message || e).slice(0, 160)}`);
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function chat(body, opts) {
|
|
134
|
+
const stream = body?.stream !== false;
|
|
135
|
+
const wantModel = typeof body?.model === "string" && body.model.startsWith("qwenwork/")
|
|
136
|
+
? body.model.slice("qwenwork/".length)
|
|
137
|
+
: (body?.model || "flash");
|
|
138
|
+
const modelKey = mapModel(wantModel);
|
|
139
|
+
const stripped = { ...body, model: wantModel };
|
|
140
|
+
const total = ring.keys.length;
|
|
141
|
+
// 构造参数无号但 auth 目录有号(如测试注入单测环境或 state 被外部改写):不视为"无号",
|
|
142
|
+
// 直接按 401 引导 login——调用方先 login 落盘再 chat,这与各 provider 门面语义一致
|
|
143
|
+
if (!total) {
|
|
144
|
+
return errRes(401, "qwenwork: 无可用账号 — 先跑 mslxdff -provider qwenwork login", "auth_error");
|
|
145
|
+
}
|
|
146
|
+
let lastRes = null;
|
|
147
|
+
const refreshed = new Set();
|
|
148
|
+
for (let hop = 0; hop <= total; hop++) {
|
|
149
|
+
const key = ring.next();
|
|
150
|
+
if (!key) break;
|
|
151
|
+
if (exhaustedByQuota.has(key)) {
|
|
152
|
+
if (ring.isCooling(key)) {
|
|
153
|
+
if (ring.available() > 0) continue;
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
exhaustedByQuota.delete(key);
|
|
157
|
+
}
|
|
158
|
+
let account = accountFor(key);
|
|
159
|
+
if (!account) { try { ring.onError(key); } catch {} continue; }
|
|
160
|
+
// 身份缺失(手动配 key、未走 login)时补一次 userinfo:COSY 身份 payload 的 uid/name/email 为空
|
|
161
|
+
// 会被上游判 `code 101 Signature invalid`(实测:同 token 空身份 403、有身份 200)。补上后才签名。
|
|
162
|
+
if (!account.uid) {
|
|
163
|
+
try {
|
|
164
|
+
const info = await userInfo(account.accessToken, fetchImpl);
|
|
165
|
+
if (info?.id) account.uid = String(info.id);
|
|
166
|
+
if (info?.name) account.nickname = String(info.name);
|
|
167
|
+
if (info?.email) account.email = String(info.email);
|
|
168
|
+
if (account.uid) {
|
|
169
|
+
try {
|
|
170
|
+
const { saveQwenworkAccount } = await import("./account-store.js");
|
|
171
|
+
await saveQwenworkAccount({ uid: account.uid, accessToken: account.accessToken, refreshToken: account.refreshToken || "", name: account.nickname || "", email: account.email || "", file });
|
|
172
|
+
} catch {}
|
|
173
|
+
}
|
|
174
|
+
} catch (e) {
|
|
175
|
+
cdbg(`[identity-enrich-fail] ${String(e?.message || e).slice(0, 160)}`);
|
|
176
|
+
}
|
|
177
|
+
if (!account.uid) { try { ring.onError(key); } catch {} lastRes = withEcho(errRes(401, "qwenwork: 账号身份缺失且 userinfo 失败,该 key 不可用", "auth_error"), account, 401); continue; }
|
|
178
|
+
}
|
|
179
|
+
const bodyStr = buildBody(stripped, modelKey);
|
|
180
|
+
let upRes;
|
|
181
|
+
try {
|
|
182
|
+
upRes = await chatRequest(account, modelKey, bodyStr, timeoutSignal(connectTimeoutMs), fetchImpl);
|
|
183
|
+
} catch (e) {
|
|
184
|
+
try { ring.onError(key); } catch {}
|
|
185
|
+
lastRes = withEcho(errRes(502, `qwenwork: 上游连接失败 ${String(e?.message || e).slice(0, 200)}`), account, 502);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (!upRes.ok) {
|
|
189
|
+
const text = await upRes.text().catch(() => "");
|
|
190
|
+
const quota = isCreditsExhausted(upRes.status, text);
|
|
191
|
+
if (quota) {
|
|
192
|
+
try { ring.onError(key, quotaCooldownMs); } catch {}
|
|
193
|
+
exhaustedByQuota.add(key);
|
|
194
|
+
lastRes = withQuota(withEcho(errRes(429, `qwenwork: 账号积分耗尽 ${text.slice(0, 200)}`, "quota_exhausted"), account, upRes.status));
|
|
195
|
+
if (ring.available() > 0) continue;
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
if ((upRes.status === 401 || upRes.status === 403) && account.refreshToken && !refreshed.has(key)) {
|
|
199
|
+
refreshed.add(key);
|
|
200
|
+
const ok = await tryRefresh(account);
|
|
201
|
+
if (ok) { hop -= 1; continue; }
|
|
202
|
+
try { ring.onError(key); } catch {}
|
|
203
|
+
lastRes = withEcho(errRes(upRes.status, `qwenwork: 授权失效 ${text.slice(0, 200)}`, "auth_error"), account, upRes.status);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
try { ring.onError(key); } catch {}
|
|
207
|
+
lastRes = withEcho(errRes(upRes.status >= 500 ? 502 : upRes.status, `qwenwork: 上游 ${upRes.status} ${text.slice(0, 200)}`), account, upRes.status);
|
|
208
|
+
if (ring.available() > 0 && (upRes.status === 429 || upRes.status >= 500)) continue;
|
|
209
|
+
return lastRes;
|
|
210
|
+
}
|
|
211
|
+
// 200:流内仍可能裹着业务错(信封 statusCodeValue / code),非流式聚合时由 collectSync 判决;
|
|
212
|
+
// 流式路径照原作者 transform 透传(错误帧由 sse.handleLine 转成流内 error 事件)。
|
|
213
|
+
if (stream) {
|
|
214
|
+
const out = new Response(transformStream(upRes.body, wantModel, () => {}), {
|
|
215
|
+
status: 200,
|
|
216
|
+
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" },
|
|
217
|
+
});
|
|
218
|
+
return withEcho(out, account, 0);
|
|
219
|
+
}
|
|
220
|
+
const result = await collectSync(upRes.body, wantModel);
|
|
221
|
+
if (result.error) {
|
|
222
|
+
if (creditsExhaustedText(result.error)) {
|
|
223
|
+
try { ring.onError(key, quotaCooldownMs); } catch {}
|
|
224
|
+
exhaustedByQuota.add(key);
|
|
225
|
+
lastRes = withQuota(withEcho(errRes(429, `qwenwork: 账号积分耗尽 ${result.error}`, "quota_exhausted"), account, 429));
|
|
226
|
+
if (ring.available() > 0) continue;
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
try { ring.onError(key); } catch {}
|
|
230
|
+
lastRes = withEcho(errRes(502, `qwenwork: 上游流内错误 ${result.error}`), account, 502);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const completion = result.completion;
|
|
234
|
+
completion.model = wantModel;
|
|
235
|
+
return withEcho(new Response(JSON.stringify(completion), { status: 200, headers: { "Content-Type": "application/json" } }), account, 0);
|
|
236
|
+
}
|
|
237
|
+
if (total > 0 && ring.keys.every((k) => exhaustedByQuota.has(k))) {
|
|
238
|
+
return errRes(429, "qwenwork: 所有账号积分已用完,请更换账号或等待额度恢复", "quota_exhausted", { "x-mslxdff-qwenwork-quota-exhausted": "1" });
|
|
239
|
+
}
|
|
240
|
+
if (lastRes) return lastRes;
|
|
241
|
+
if (!ring.available()) {
|
|
242
|
+
return errRes(429, "qwenwork: 账号暂不可用(上游限流/排队冷却中),请稍后重试", "all_cooling", { "x-mslxdff-qwenwork-all-cooling": "1" });
|
|
243
|
+
}
|
|
244
|
+
return errRes(401, "qwenwork: 无可用账号 — 先跑 mslxdff -provider qwenwork login", "auth_error");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 全号聚合:逐号拉 qwork 切片求并集(单号 3 个,多号互相印证)
|
|
248
|
+
let cache = null;
|
|
249
|
+
let fetchedAt = 0;
|
|
250
|
+
function eachAccount() {
|
|
251
|
+
const out = [];
|
|
252
|
+
const seen = new Set();
|
|
253
|
+
const push = (key, auth) => {
|
|
254
|
+
const account = accountFor(key);
|
|
255
|
+
if (!account?.accessToken || seen.has(account.accessToken)) return;
|
|
256
|
+
seen.add(account.accessToken);
|
|
257
|
+
out.push(account);
|
|
258
|
+
};
|
|
259
|
+
for (const k of keys) {
|
|
260
|
+
const blob = accountFromBlob(k);
|
|
261
|
+
if (!blob?.accessToken) continue;
|
|
262
|
+
const auth = authList.find((a) => String(a?.refreshToken || "") === String(blob.refreshToken || "")) || {};
|
|
263
|
+
push(k, auth);
|
|
264
|
+
}
|
|
265
|
+
if (!out.length) {
|
|
266
|
+
try {
|
|
267
|
+
for (const { uid, doc } of listAccountDocs()) {
|
|
268
|
+
const a = doc?.auth || {};
|
|
269
|
+
if (!a.accessToken) continue;
|
|
270
|
+
const account = { id: uid, uid, nickname: doc?.account?.name || "", email: doc?.account?.email || "", accessToken: a.accessToken, refreshToken: a.refreshToken || "" };
|
|
271
|
+
if (seen.has(account.accessToken)) continue;
|
|
272
|
+
seen.add(account.accessToken);
|
|
273
|
+
out.push(account);
|
|
274
|
+
}
|
|
275
|
+
} catch {}
|
|
276
|
+
}
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function listModels() {
|
|
281
|
+
const now = Date.now();
|
|
282
|
+
if (cache && now - fetchedAt < MODELS_CACHE_MS) return cache;
|
|
283
|
+
const accounts = eachAccount();
|
|
284
|
+
if (!accounts.length) return [];
|
|
285
|
+
const seen = new Set();
|
|
286
|
+
const out = [];
|
|
287
|
+
for (const account of accounts) {
|
|
288
|
+
try {
|
|
289
|
+
const list = await modelList(account, fetchImpl);
|
|
290
|
+
for (const m of list) {
|
|
291
|
+
if (!m?.key || seen.has(m.key)) continue;
|
|
292
|
+
seen.add(m.key);
|
|
293
|
+
out.push({
|
|
294
|
+
id: joinModelId(id, m.key),
|
|
295
|
+
object: "model",
|
|
296
|
+
created: 1753600000,
|
|
297
|
+
owned_by: "qwenwork",
|
|
298
|
+
name: m.name || m.key,
|
|
299
|
+
context_length: m.maxInputTokens || 0,
|
|
300
|
+
price_factor: m.priceFactor || 0,
|
|
301
|
+
is_reasoning: !!m.isReasoning,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
} catch {}
|
|
305
|
+
}
|
|
306
|
+
if (out.length) { cache = out; fetchedAt = now; }
|
|
307
|
+
// 取数失败时用实测快照兜底(目录恒为 3 个,不会空)
|
|
308
|
+
if (!out.length && !cache) {
|
|
309
|
+
return KNOWN_MODELS.map((m) => ({
|
|
310
|
+
id: joinModelId(id, m.key),
|
|
311
|
+
object: "model",
|
|
312
|
+
created: 1753600000,
|
|
313
|
+
owned_by: "qwenwork",
|
|
314
|
+
name: m.name,
|
|
315
|
+
context_length: 1000000,
|
|
316
|
+
price_factor: m.priceFactor,
|
|
317
|
+
is_reasoning: true,
|
|
318
|
+
}));
|
|
319
|
+
}
|
|
320
|
+
return out.length ? out : (cache || []);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function preheat() {
|
|
324
|
+
try {
|
|
325
|
+
const list = await listModels();
|
|
326
|
+
return list.length ? { ok: true } : { ok: false, error: "no account" };
|
|
327
|
+
} catch (e) { return { ok: false, error: String(e?.message || e).slice(0, 120) }; }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function close() {}
|
|
331
|
+
async function chatWithKeys(body, keysOverride, opts) {
|
|
332
|
+
const tmp = createQwenworkProvider({ id, apiKeys: keysOverride, file, fetchImpl, cooldownMs, quotaCooldownMs, connectTimeoutMs });
|
|
333
|
+
return tmp.chat(body, opts);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return { id, chat, chatWithKeys, listModels, preheat, close, keyRing: ring, baseUrl: "qwenwork://native" };
|
|
337
|
+
}
|