mslxdff 0.1.71 → 0.1.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.71",
3
+ "version": "0.1.73",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -44,7 +44,7 @@ export function formatReport(results, { json = false } = {}) {
44
44
  else if (r.charsPerSec != null) speed = `${r.charsPerSec} 字/秒`;
45
45
  speed = pad(speed, 11, "right");
46
46
  const tok = r.tokens?.completion != null ? String(r.tokens.completion) : r.chars != null ? String(r.chars) : "—";
47
- const note = r.ok ? "" : (r.error || "").slice(0, 28);
47
+ const note = r.ok ? "" : (r.error || "").slice(0, 50);
48
48
  lines.push(`${mark}${id} ${label} ${ttfb} ${total} ${speed} ${pad(tok, 6, "right")} ${note}`);
49
49
  }
50
50
  lines.push("─".repeat(84));
@@ -1,10 +1,24 @@
1
1
  import { joinUrl } from "../providers/base.js";
2
2
  import { computeMetrics, extractUsageFromJson } from "../metrics.js";
3
3
 
4
+ function extractInnerMessage(bodyText) {
5
+ const t = String(bodyText || "");
6
+ try {
7
+ const j = JSON.parse(t);
8
+ const m = j?.error?.message || j?.error || j?.message || j?.data?.error || "";
9
+ if (typeof m === "string" && m.trim()) return m.trim().slice(0, 300);
10
+ if (typeof j?.error === "string") return j.error.slice(0, 300);
11
+ } catch {}
12
+ return t.slice(0, 300);
13
+ }
14
+
4
15
  function classifyError(status, bodyText) {
5
- const t = String(bodyText || "").slice(0, 300);
16
+ const t = String(bodyText || "").slice(0, 500);
17
+ const low = t.toLowerCase();
6
18
  if (status === 401) return { label: "鉴权失败", retryable: false };
7
19
  if (status === 402 || /insufficient balance/i.test(t)) return { label: "余额不足", retryable: false };
20
+ if (low.includes("only available via cline")) return { label: "仅 Cline 客户端可用", retryable: false };
21
+ if (low.includes("invalid model format")) return { label: "模型格式错误", retryable: false };
8
22
  if (status === 403) return { label: /insufficient/i.test(t) ? "余额不足" : "鉴权失败", retryable: false };
9
23
  if (status === 429) return { label: "限流", retryable: true };
10
24
  if (status >= 500) return { label: `上游错误 ${status}`, retryable: true };
@@ -55,7 +69,8 @@ export async function runOne({
55
69
  let txt = "";
56
70
  try { txt = await res.text(); } catch {}
57
71
  const cls = classifyError(res.status, txt);
58
- return { id: model, providerId, ok: false, status: res.status, error: txt.slice(0, 300) || `HTTP ${res.status}`, label: cls.label, ttfbMs, totalMs, tps: null, charsPerSec: null, tokens: null };
72
+ const msg = extractInnerMessage(txt) || `HTTP ${res.status}`;
73
+ return { id: model, providerId, ok: false, status: res.status, error: msg, label: cls.label, ttfbMs, totalMs, tps: null, charsPerSec: null, tokens: null };
59
74
  }
60
75
  let json = {};
61
76
  let txt = "";
@@ -2,6 +2,67 @@ import { probeModels } from "../../../bench/probe.js";
2
2
  import { runOne } from "../../../bench/runner.js";
3
3
  import { formatReport } from "../../../bench/report.js";
4
4
  import { defaultModelsPath, defaultChatPath } from "../../../state/provider-config.js";
5
+ import { isRefreshToken, clineHeaders } from "../../../providers/cline/headers.js";
6
+ import { refreshTokenForBase } from "../../../providers/cline/auth.js";
7
+ import { computeMetrics } from "../../../metrics.js";
8
+
9
+ // Cline 免费通道(deepseek/z-ai 等)非流式会被上游限流 500 empty response content,
10
+ // 必须 stream:true + SSE 聚合后测速
11
+ async function clineBenchOne({ baseUrl, model, accessToken, prompt, maxTokens, timeoutMs, fetchImpl }) {
12
+ const controller = new AbortController();
13
+ const timer = setTimeout(() => controller.abort(new Error(`timeout ${timeoutMs}ms`)), timeoutMs);
14
+ const t0 = performance.now();
15
+ let ttfbMs = null;
16
+ let content = "";
17
+ try {
18
+ const res = await fetchImpl(`${baseUrl}/api/v1/chat/completions`, {
19
+ method: "POST",
20
+ headers: { ...clineHeaders(`sess_bench_${Date.now()}`, accessToken), Accept: "text/event-stream" },
21
+ body: JSON.stringify({ model, messages: [{ role: "user", content: prompt }], stream: true, max_tokens: maxTokens, session_id: `sess_bench_${Date.now()}`, reasoning_effort: "high" }),
22
+ signal: controller.signal,
23
+ });
24
+ if (res instanceof Error) throw res;
25
+ if (!res.ok) {
26
+ let txt = "";
27
+ try { txt = await res.text(); } catch {}
28
+ const low = txt.toLowerCase();
29
+ const label = res.status === 401 ? "鉴权失败" : res.status === 429 ? "限流" : res.status >= 500 ? `上游错误 ${res.status}` : `HTTP ${res.status}`;
30
+ return { id: model, ok: false, status: res.status, label, error: txt.slice(0, 300), ttfbMs, totalMs: Math.round(performance.now() - t0), tps: null, charsPerSec: null, tokens: null };
31
+ }
32
+ const reader = res.body.getReader();
33
+ const decoder = new TextDecoder();
34
+ let buf = "";
35
+ const firstChunkAt = performance.now();
36
+ for (;;) {
37
+ const { done, value } = await reader.read();
38
+ if (done) break;
39
+ if (ttfbMs === null) ttfbMs = Math.round(performance.now() - firstChunkAt);
40
+ buf += decoder.decode(value, { stream: true });
41
+ let idx;
42
+ while ((idx = buf.indexOf("\n")) >= 0) {
43
+ const line = buf.slice(0, idx);
44
+ buf = buf.slice(idx + 1);
45
+ if (!line.startsWith("data:")) continue;
46
+ const payload = line.slice(5).trim();
47
+ if (!payload || payload === "[DONE]") continue;
48
+ try {
49
+ const j = JSON.parse(payload);
50
+ const c = j?.choices?.[0]?.delta?.content || j?.choices?.[0]?.message?.content || "";
51
+ if (typeof c === "string") content += c;
52
+ } catch {}
53
+ }
54
+ }
55
+ const totalMs = Math.round(performance.now() - t0);
56
+ const chars = content.length;
57
+ const { tps, charsPerSec } = computeMetrics({ ttfbMs, totalMs, promptTokens: null, completionTokens: null, chars });
58
+ return { id: model, ok: true, status: 200, label: "成功", ttfbMs, totalMs, tps, charsPerSec, tokens: null, chars };
59
+ } catch (e) {
60
+ const msg = e?.message || String(e);
61
+ return { id: model, ok: false, label: /timeout|abort/i.test(msg) ? "超时" : "网络错误", error: msg.slice(0, 300), ttfbMs, totalMs: Math.round(performance.now() - t0), tps: null, charsPerSec: null, tokens: null };
62
+ } finally {
63
+ clearTimeout(timer);
64
+ }
65
+ }
5
66
 
6
67
  function parseBenchArgs(rest) {
7
68
  const opts = { json: false, prompt: "hi", maxTokens: 32, timeoutMs: 30000 };
@@ -106,19 +167,44 @@ export async function handleProviderBench(id, sub, rest, args, deps = {}) {
106
167
  }
107
168
 
108
169
  // 有勾选 → 逐个测
109
- console.log(`bench ${providerId}: ${allowed.length} 个已勾选模型,逐个测速(串行,${opts.timeoutMs}ms 超时)...`);
170
+ const log = (s) => (opts.json ? console.error(s) : console.log(s));
171
+ log(`bench ${providerId}: 共 ${allowed.length} 个已勾选模型,逐个测速(串行,${opts.timeoutMs}ms 超时)...`);
110
172
  if (!opts.json) console.log(`prompt="${opts.prompt}" maxTokens=${opts.maxTokens}\n`);
111
173
  const chatPath = cfg.chatPath || defaultChatPath(providerId);
174
+ // Cline 新链:keys 含 refreshToken 时走 refresh→workos token + 指纹头(绕 403/401),
175
+ // 且 chat 固定拼 https://<host>/api/v1/chat/completions(剥掉 baseUrl 里可能带的 /api/v1)
176
+ const rtKeys = keys.filter((k) => isRefreshToken(k));
177
+ const normBase = String(baseUrl).replace(/\/+$/, "");
178
+ const clineChatBase = normBase.endsWith("/api/v1") ? normBase.slice(0, -7) : normBase;
112
179
  const results = [];
113
180
  for (let i = 0; i < allowed.length; i++) {
114
181
  const raw = allowed[i];
115
182
  const model = String(raw || "").trim();
116
183
  if (!opts.json) process.stdout.write(` [${i + 1}/${allowed.length}] ${model} ... `);
117
184
  // 轮询 key/auth:按索引取,超长循环
118
- const kIdx = i % keys.length;
119
- const aIdx = Math.min(kIdx, auths.length - 1);
185
+ const kIdx = i % (keys.length || 1);
186
+ const aIdx = Math.min(kIdx, Math.max(auths.length - 1, 0));
120
187
  const key = keys[kIdx];
121
188
  const auth = auths[aIdx] || auths[0] || null;
189
+
190
+ if (rtKeys.length) {
191
+ const rt = rtKeys[kIdx % rtKeys.length];
192
+ const at = await refreshTokenForBase({ refreshToken: rt, baseUrl: normBase, fetchImpl });
193
+ if (!at) {
194
+ const r = { id: model, ok: false, error: "refreshToken 换 accessToken 失败", label: "鉴权失败", ttfbMs: null, totalMs: 0, tps: null, charsPerSec: null, tokens: null };
195
+ results.push(r);
196
+ if (!opts.json) console.log(`FAIL ${r.label} (${r.error})`);
197
+ continue;
198
+ }
199
+ const r = await clineBenchOne({ baseUrl: clineChatBase, model, accessToken: at, prompt: opts.prompt, maxTokens: opts.maxTokens, timeoutMs: opts.timeoutMs, fetchImpl });
200
+ results.push(r);
201
+ if (!opts.json) {
202
+ if (r.ok) console.log(`OK TTFB ${r.ttfbMs}ms 总 ${r.totalMs}ms ${r.tps != null ? `${r.tps} t/s` : r.charsPerSec != null ? `${r.charsPerSec} 字/秒` : "—"}`);
203
+ else console.log(`FAIL ${r.label} ${r.error ? `(${r.error.slice(0, 60)})` : ""}`);
204
+ }
205
+ continue;
206
+ }
207
+
122
208
  const headers = buildHeadersForProvider(providerId, key, auth);
123
209
  const r = await runOne({ baseUrl, chatPath, model, providerId, apiKey: key, headers, prompt: opts.prompt, maxTokens: opts.maxTokens, timeoutMs: opts.timeoutMs, fetchImpl });
124
210
  results.push(r);
@@ -0,0 +1,112 @@
1
+ export async function handleClineLogin(id, sub) {
2
+ if (sub !== "login" && sub !== "auth" && sub !== "oauth") return false;
3
+ if (id !== "cline" && id !== "clinebot" && id !== "cline-bot") return false;
4
+
5
+ const CLIENT_ID = "client_01K3A541FN8TA3EPPHTD2325AR";
6
+ const WORKOS_DEVICE = "https://api.workos.com/user_management/authorize/device";
7
+ const WORKOS_AUTH = "https://api.workos.com/user_management/authenticate";
8
+ const CLINE_REGISTER = "https://api.cline.bot/api/v1/auth/register";
9
+
10
+ async function postForm(url, form) {
11
+ const body = new URLSearchParams(form).toString();
12
+ const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body });
13
+ const txt = await res.text();
14
+ try { return JSON.parse(txt); } catch { throw new Error(`WorkOS 返回非 JSON: ${txt.slice(0, 200)}`); }
15
+ }
16
+ async function postJson(url, obj) {
17
+ const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(obj) });
18
+ const txt = await res.text();
19
+ try { return JSON.parse(txt); } catch { throw new Error(`Cline 返回非 JSON: ${txt.slice(0, 200)}`); }
20
+ }
21
+
22
+ console.log("🚀 启动 Cline WorkOS 设备授权流程...\n");
23
+ let device;
24
+ try {
25
+ device = await postForm(WORKOS_DEVICE, { client_id: CLIENT_ID });
26
+ } catch (e) {
27
+ console.error(`❌ 获取设备码失败: ${e.message}`);
28
+ process.exit(1);
29
+ }
30
+ const device_code = device.device_code;
31
+ const user_code = device.user_code;
32
+ const auth_url = device.verification_uri_complete || device.verification_uri;
33
+ const interval = Math.max(Number(device.interval || 5), 5);
34
+ const expires_in = Number(device.expires_in || 300);
35
+ if (!device_code || !user_code || !auth_url) {
36
+ console.error("❌ WorkOS 返回不完整:", JSON.stringify(device).slice(0, 500));
37
+ process.exit(1);
38
+ }
39
+ console.log("=".repeat(60));
40
+ console.log("1️⃣ 在浏览器打开下面这个链接:");
41
+ console.log(` ${auth_url}`);
42
+ console.log("2️⃣ 页面会要求输入设备码(可能已自动带好):");
43
+ console.log(` ${user_code}`);
44
+ console.log("3️⃣ 用 Google / GitHub / 邮箱登录并授权");
45
+ console.log("=".repeat(60));
46
+ console.log(`\n🔄 等待你授权(自动轮询,每 ${interval}s,最多 ${expires_in}s)...`);
47
+ console.log(" (已授权后会自动继续,无需回车)\n");
48
+
49
+ const deadline = Date.now() + expires_in * 1000;
50
+ let workos = null;
51
+ while (Date.now() < deadline) {
52
+ await new Promise((r) => setTimeout(r, interval * 1000));
53
+ try {
54
+ const a = await postForm(WORKOS_AUTH, { grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code, client_id: CLIENT_ID });
55
+ if (a.access_token) { workos = a; break; }
56
+ const err = a.error;
57
+ if (err === "slow_down") { await new Promise((r) => setTimeout(r, 5000)); }
58
+ else if (err && err !== "authorization_pending") {
59
+ console.log(` [${err}] ${a.error_description || ""}`);
60
+ } else {
61
+ process.stdout.write(".");
62
+ }
63
+ } catch (e) {
64
+ console.log(`\n 轮询出错: ${e.message}`);
65
+ }
66
+ }
67
+ if (!workos) {
68
+ console.error("\n❌ 授权超时,请重新运行 mslxdff -provider clinebot login");
69
+ process.exit(1);
70
+ }
71
+ console.log("\n✅ WorkOS 授权成功!\n🔗 用 WorkOS token 在 Cline 注册...");
72
+ let cline;
73
+ try {
74
+ cline = await postJson(CLINE_REGISTER, { accessToken: workos.access_token, refreshToken: workos.refresh_token });
75
+ } catch (e) {
76
+ console.error(`❌ Cline 注册失败: ${e.message}`);
77
+ process.exit(1);
78
+ }
79
+ const rt = cline?.data?.refreshToken || cline?.refreshToken;
80
+ const email = cline?.data?.userInfo?.email || cline?.data?.email || "unknown";
81
+ if (!rt) {
82
+ console.error("❌ 注册失败,未拿到 refreshToken:", JSON.stringify(cline).slice(0, 800));
83
+ process.exit(1);
84
+ }
85
+ console.log("=".repeat(60));
86
+ console.log(`✅ 登录成功! 账号: ${email}`);
87
+ console.log(`🔑 refreshToken: ${rt.slice(0, 8)}…${rt.slice(-8)} (${rt.length} 字符)`);
88
+ // 落盘到 state(同时写 cline 与 clinebot 两个 id,兼容)
89
+ const { loadProviderKeys, saveProviderConfig, loadProviderConfig } = await import("../../../state.js");
90
+ for (const pid of ["cline", "clinebot"]) {
91
+ try {
92
+ const cur = loadProviderKeys(pid);
93
+ if (cur.includes(rt)) {
94
+ console.log(` ℹ️ ${pid} 已存在相同 token,跳过`);
95
+ continue;
96
+ }
97
+ const cfg = loadProviderConfig(pid) || { baseUrl: "", keys: [] };
98
+ const nextKeys = [...new Set([...(cfg.keys || cur), rt].filter(Boolean))];
99
+ saveProviderConfig(pid, { baseUrl: cfg.baseUrl || "https://api.cline.bot", keys: nextKeys });
100
+ console.log(` ✅ 已写入 ${pid}(现 ${nextKeys.length} 个账号)`);
101
+ } catch (e) {
102
+ console.log(` ⚠️ 写入 ${pid} 失败: ${e.message}`);
103
+ }
104
+ }
105
+ console.log("=".repeat(60));
106
+ console.log("\n下一步:");
107
+ console.log(" mslxdff -restart 重启网关使新账号生效");
108
+ console.log(" mslxdff -provider clinebot bench --json 测速 deepseek 是否 200");
109
+ console.log(" mslxdff -chat 直接对话,模型选 deepseek/deepseek-v4-flash");
110
+ console.log("\n多账号:重复 `mslxdff -provider clinebot login` 追加,二号自动做后备");
111
+ process.exit(0);
112
+ }
@@ -181,6 +181,8 @@ export async function handleProvider(args) {
181
181
  console.log(` allowlist: mslxdff -provider opencode allowlist [list|set|add|remove|clear] — restrict models (empty=allow all)`);
182
182
  process.exit(0);
183
183
  }
184
+ const { handleClineLogin } = await import("./cline-login.js");
185
+ if (await handleClineLogin(id, sub)) return true;
184
186
  const { handleProviderConfig } = await import("./config.js");
185
187
  if (await handleProviderConfig(id, sub, rest)) return true;
186
188
  const { handleProviderAllowlist } = await import("./allowlist.js");
@@ -0,0 +1,156 @@
1
+ import { joinUrl, sleep } from "../base.js";
2
+
3
+ /**
4
+ * Cline Token 池:多账号 round-robin + refresh 换 accessToken + 冷却 + 队列
5
+ * 对标 pingmike2/cline2api-workers 的 accounts / getAccountToken / parseCooldown / enqueue
6
+ */
7
+
8
+ export function parseCooldown(body, status) {
9
+ const m = String(body || "").match(/try again in (?:(\d+)\s*h)?\s*(?:(\d+)\s*m)?\s*(?:(\d+)\s*s)?/i);
10
+ if (m) {
11
+ const h = parseInt(m[1] || 0, 10);
12
+ const min = parseInt(m[2] || 0, 10);
13
+ const s = parseInt(m[3] || 0, 10);
14
+ const ms = (h * 3600 + min * 60 + s) * 1000;
15
+ if (ms > 0) return Math.min(ms, 6 * 3600 * 1000);
16
+ }
17
+ if (status === 429) return 5 * 60 * 1000;
18
+ return 60 * 1000;
19
+ }
20
+
21
+ /**
22
+ * 一次性 refresh:bench/诊断用,不落盘、不建池。
23
+ * 返回 accessToken 或 null。
24
+ */
25
+ export async function refreshTokenForBase({ refreshToken, baseUrl = "https://api.cline.bot", fetchImpl = globalThis.fetch, dispatcher } = {}) {
26
+ const rt = String(refreshToken || "").trim();
27
+ if (!rt) return null;
28
+ const resolvedBase = String(baseUrl).trim().replace(/\/+$/, "") || "https://api.cline.bot";
29
+ const baseNoV1 = resolvedBase.endsWith("/api/v1") ? resolvedBase.slice(0, -7) : resolvedBase;
30
+ const url = joinUrl(baseNoV1, "/api/v1/auth/refresh");
31
+ const opts = {
32
+ method: "POST",
33
+ headers: { "Content-Type": "application/json" },
34
+ body: JSON.stringify({ refreshToken: rt, grantType: "refresh_token" }),
35
+ };
36
+ if (dispatcher) opts.dispatcher = dispatcher;
37
+ try {
38
+ const res = await fetchImpl(url, opts);
39
+ if (!res.ok) return null;
40
+ const data = await res.json();
41
+ return data?.data?.accessToken || data?.accessToken || data?.access_token || null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ export function createAuthPool({
48
+ id = "cline",
49
+ keys = [],
50
+ fetchImpl,
51
+ dispatcher,
52
+ baseUrl = "https://api.cline.bot",
53
+ file,
54
+ clock = Date.now,
55
+ saveFn,
56
+ } = {}) {
57
+ const resolvedBase = String(baseUrl).trim().replace(/\/+$/, "") || "https://api.cline.bot";
58
+ let accounts = [];
59
+ let accountIndex = 0;
60
+ let currentAccount = null;
61
+ let queueTail = Promise.resolve();
62
+ const MIN_GAP_MS = 800;
63
+
64
+ function parseAccounts(tokens) {
65
+ const list = [...new Set((tokens || []).map((s) => String(s).trim()).filter((s) => s.length > 8))];
66
+ if (list.length === 0) {
67
+ accounts = [];
68
+ return accounts;
69
+ }
70
+ const changed = accounts.length !== list.length || accounts.some((a, i) => a.refreshToken !== list[i]);
71
+ if (changed) {
72
+ accounts = list.map((rt) => ({ refreshToken: rt, accessToken: null, expiry: 0, cooldownUntil: 0 }));
73
+ accountIndex = 0;
74
+ }
75
+ return accounts;
76
+ }
77
+
78
+ // 初始解析
79
+ parseAccounts(keys);
80
+
81
+ function enqueue(fn) {
82
+ const run = queueTail.then(() => sleep(MIN_GAP_MS)).then(fn);
83
+ queueTail = run.catch(() => {});
84
+ return run;
85
+ }
86
+
87
+ function pickAccount(pool) {
88
+ const list = pool || accounts;
89
+ for (let k = 0; k < list.length; k++) {
90
+ const acc = list[accountIndex % list.length];
91
+ accountIndex = (accountIndex + 1) % list.length;
92
+ if (!acc.cooldownUntil || acc.cooldownUntil <= clock()) {
93
+ currentAccount = acc;
94
+ return acc;
95
+ }
96
+ }
97
+ return null;
98
+ }
99
+
100
+ async function refreshOne(account) {
101
+ const now = clock();
102
+ if (account.cooldownUntil > now) throw new Error("account_cooldown");
103
+ if (account.accessToken && now < account.expiry) return account.accessToken;
104
+ const url = joinUrl(resolvedBase, "/api/v1/auth/refresh");
105
+ const opts = {
106
+ method: "POST",
107
+ headers: { "Content-Type": "application/json" },
108
+ body: JSON.stringify({ refreshToken: account.refreshToken, grantType: "refresh_token" }),
109
+ };
110
+ if (dispatcher) opts.dispatcher = dispatcher;
111
+ let res;
112
+ try { res = await fetchImpl(url, opts); } catch { account.cooldownUntil = now + 60 * 1000; throw new Error("refresh_failed"); }
113
+ if (!res.ok) { account.cooldownUntil = now + 60 * 1000; throw new Error("refresh_failed"); }
114
+ let data;
115
+ try { data = await res.json(); } catch { account.cooldownUntil = now + 60 * 1000; throw new Error("refresh_no_token"); }
116
+ const accessToken = data?.data?.accessToken || data?.accessToken || data?.access_token;
117
+ if (!accessToken) { account.cooldownUntil = now + 60 * 1000; throw new Error("refresh_no_token"); }
118
+ const newRt = typeof data?.data?.refreshToken === "string" && data.data.refreshToken.trim() ? data.data.refreshToken.trim() : null;
119
+ if (newRt && newRt !== account.refreshToken) {
120
+ const oldRt = account.refreshToken;
121
+ account.refreshToken = newRt;
122
+ if (saveFn) {
123
+ try { await saveFn({ oldRefreshToken: oldRt, newRefreshToken: newRt, newAccessToken: accessToken }); } catch {}
124
+ }
125
+ }
126
+ account.accessToken = accessToken;
127
+ const expiresAt = data?.data?.expiresAt ?? data?.expiresAt;
128
+ let expiry = now + 10 * 60 * 1000;
129
+ if (typeof expiresAt === "number") expiry = expiresAt;
130
+ else if (typeof expiresAt === "string") { const t = Date.parse(expiresAt); if (!isNaN(t)) expiry = t; }
131
+ account.expiry = expiry - 60 * 1000;
132
+ return accessToken;
133
+ }
134
+
135
+ async function getAccessToken() {
136
+ const pool = accounts;
137
+ if (pool.length === 0) throw new Error("缺少 CLINE_REFRESH_TOKEN(请用 cline_oauth.py 获取)");
138
+ for (let attempt = 0; attempt < pool.length; attempt++) {
139
+ const acc = pool[attempt % pool.length];
140
+ if (acc.cooldownUntil && acc.cooldownUntil > clock()) continue;
141
+ currentAccount = acc;
142
+ try { return await refreshOne(acc); } catch (e) { if (e.message === "account_cooldown") continue; continue; }
143
+ }
144
+ const acc = pool[0];
145
+ if (!acc) throw new Error("无可用 Cline 账号");
146
+ currentAccount = acc;
147
+ acc.cooldownUntil = 0;
148
+ return refreshOne(acc);
149
+ }
150
+
151
+ function getCurrentAccount() { return currentAccount; }
152
+ function getAccounts() { return accounts; }
153
+ function updateKeys(newKeys) { parseAccounts(newKeys); }
154
+
155
+ return { parseAccounts, getAccessToken, refreshOne, pickAccount, parseCooldown, enqueue, getCurrentAccount, getAccounts, updateKeys, _accounts: () => accounts };
156
+ }
@@ -0,0 +1,214 @@
1
+ import { joinUrl, sleep } from "../base.js";
2
+ import { clineHeaders } from "./headers.js";
3
+
4
+ function genSessionId() { return `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; }
5
+
6
+ function unwrapData(obj) {
7
+ if (obj && obj.data && typeof obj.data === "object") {
8
+ const d = obj.data;
9
+ if (d.choices || d.id || d.usage || d.output) return d;
10
+ }
11
+ return obj;
12
+ }
13
+
14
+ async function streamToNonStream(upstream) {
15
+ const reader = upstream.body.getReader();
16
+ const decoder = new TextDecoder();
17
+ let buf = "";
18
+ let content = "";
19
+ let reasoning = "";
20
+ let finishReason = null;
21
+ let model = "";
22
+ let id = "";
23
+ let usage = null;
24
+ while (true) {
25
+ const { done, value } = await reader.read();
26
+ if (done) break;
27
+ buf += decoder.decode(value, { stream: true });
28
+ let idx;
29
+ while ((idx = buf.indexOf("\n")) >= 0) {
30
+ const line = buf.slice(0, idx);
31
+ buf = buf.slice(idx + 1);
32
+ if (!line.startsWith("data:")) continue;
33
+ const payload = line.slice(5).trim();
34
+ if (!payload || payload === "[DONE]") continue;
35
+ try {
36
+ const obj = JSON.parse(payload);
37
+ const normalized = unwrapData(obj);
38
+ const choice = normalized?.choices?.[0];
39
+ if (!choice) continue;
40
+ const delta = choice.delta || {};
41
+ if (delta.content) content += delta.content;
42
+ if (delta.reasoning) reasoning += delta.reasoning;
43
+ if (choice.finish_reason) finishReason = choice.finish_reason;
44
+ if (normalized.id) id = normalized.id;
45
+ if (normalized.model) model = normalized.model;
46
+ if (normalized.usage) usage = normalized.usage;
47
+ } catch {}
48
+ }
49
+ }
50
+ const msg = { role: "assistant", content };
51
+ if (reasoning) msg.reasoning = reasoning;
52
+ if (!content && reasoning) { msg.content = reasoning; msg.reasoning_used_as_content = true; }
53
+ return {
54
+ id: id || `gen_${Date.now()}`,
55
+ object: "chat.completion",
56
+ created: Math.floor(Date.now() / 1000),
57
+ model: model || "",
58
+ choices: [{ index: 0, message: msg, finish_reason: finishReason || "stop", logprobs: null, native_finish_reason: finishReason || "stop" }],
59
+ usage: usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
60
+ };
61
+ }
62
+
63
+ export function createChatService({
64
+ id,
65
+ baseUrl,
66
+ chatPath,
67
+ fetchImpl,
68
+ dispatcher,
69
+ authPool,
70
+ connectTimeoutMs = 30_000,
71
+ retry = { network: { attempts: 2, delayMs: 300 } },
72
+ } = {}) {
73
+ const resolvedBase = String(baseUrl).trim().replace(/\/+$/, "");
74
+ // chat 固定落在 /api/v1/chat/completions:base 已含 /api/v1 则只拼 /chat/completions,
75
+ // 否则拼 /api/v1/chat/completions(避免双 /api/v1 或 /v1 错路径导致 401/empty response)
76
+ const resolvedChat = chatPath || (String(resolvedBase).includes("/api/v1") ? "/chat/completions" : "/api/v1/chat/completions");
77
+
78
+ async function clineFetch(body, sessionId) {
79
+ const token = await authPool.getAccessToken();
80
+ const headers = clineHeaders(sessionId, token);
81
+ const finalUrl = joinUrl(resolvedBase, resolvedChat);
82
+ const opts = { method: "POST", headers, body: JSON.stringify(body) };
83
+ if (dispatcher) opts.dispatcher = dispatcher;
84
+ const controller = new AbortController();
85
+ const timer = setTimeout(() => controller.abort(new Error(`${id} timed out after ${connectTimeoutMs}ms`)), connectTimeoutMs);
86
+ opts.signal = controller.signal;
87
+ try { return await fetchImpl(finalUrl, opts); } finally { clearTimeout(timer); }
88
+ }
89
+
90
+ // 判断限流信号
91
+ function isLimitHit(status, bodyText, isStream) {
92
+ if (status === 429) return true;
93
+ if (status >= 500 && String(bodyText).includes("empty response content")) return true;
94
+ if (status === 200 && !isStream && String(bodyText).includes("empty response content")) return true;
95
+ return false;
96
+ }
97
+
98
+ async function clineFetchWithRetry(body, sessionId, isStream) {
99
+ const maxRetries = 4;
100
+ let lastResp = null;
101
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
102
+ const resp = await authPool.enqueue(() => clineFetch(body, sessionId));
103
+ lastResp = resp;
104
+ let bodyText = "";
105
+ try { bodyText = await resp.clone().text(); } catch {}
106
+ const hit = isLimitHit(resp.status, bodyText, isStream);
107
+ if (hit) {
108
+ const { parseCooldown } = await import("./auth.js");
109
+ const cooldownMs = parseCooldown(bodyText, resp.status);
110
+ const cur = authPool.getCurrentAccount();
111
+ if (cur) { cur.cooldownUntil = Date.now() + cooldownMs; cur.accessToken = null; cur.expiry = 0; }
112
+ const pool = authPool.getAccounts();
113
+ const hasOther = pool.some((a) => !a.cooldownUntil || a.cooldownUntil <= Date.now());
114
+ if (!hasOther) return resp;
115
+ await sleep(500 + Math.floor(Math.random() * 500));
116
+ continue;
117
+ }
118
+ if (resp.ok) return resp;
119
+ return resp;
120
+ }
121
+ return lastResp;
122
+ }
123
+
124
+ async function nonStreamWithContentCheck(body, sessionId, firstResp) {
125
+ const maxAttempts = 3;
126
+ let lastData = null;
127
+ let resp = firstResp;
128
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
129
+ if (!resp) resp = await clineFetchWithRetry(body, sessionId, true);
130
+ if (!resp.ok) {
131
+ const errText = await resp.text().catch(() => "");
132
+ const hdrs = new Headers(resp.headers);
133
+ const errBody = JSON.stringify({ error: { message: errText.slice(0, 500), type: "api_error" } });
134
+ return { error: new Response(errBody, { status: resp.status, headers: hdrs }) };
135
+ }
136
+ const ct = resp.headers.get("content-type") || "";
137
+ let normalized = null;
138
+ if (ct.includes("text/event-stream")) normalized = await streamToNonStream(resp);
139
+ else {
140
+ const raw = await resp.json().catch(() => null);
141
+ if (raw) normalized = unwrapData(raw);
142
+ }
143
+ if (!normalized) return { error: new Response(JSON.stringify({ error: { message: "upstream returned non-SSE body", type: "api_error" } }), { status: 502 }) };
144
+ lastData = normalized;
145
+ const msg = normalized?.choices?.[0]?.message || {};
146
+ const content = String(msg.content || "").trim();
147
+ const reasoning = String(msg.reasoning || "").trim();
148
+ const isFallback = msg.reasoning_used_as_content === true;
149
+ if (content && !isFallback) return { data: normalized };
150
+ if (reasoning || isFallback) {
151
+ const cur = authPool.getCurrentAccount();
152
+ if (cur) { cur.cooldownUntil = Date.now() + 30 * 1000; cur.accessToken = null; cur.expiry = 0; }
153
+ await sleep(300 + Math.floor(Math.random() * 300));
154
+ resp = null;
155
+ continue;
156
+ }
157
+ await sleep(300 + Math.floor(Math.random() * 300));
158
+ resp = null;
159
+ }
160
+ return { data: lastData };
161
+ }
162
+
163
+ async function runChat(body, ring, sourceKey) {
164
+ const model = body?.model || "deepseek/deepseek-v4-flash";
165
+ const sessionId = genSessionId();
166
+ const isStream = body?.stream === true;
167
+ const upstreamModel = String(model).split("/").pop().includes(":") ? model : model;
168
+ // 构造上游 body:保留外部 model 名,Cline 上游用同名
169
+ const upstreamBody = {
170
+ model: upstreamModel,
171
+ max_tokens: body?.max_tokens || body?.max_completion_tokens || 4096,
172
+ session_id: sessionId,
173
+ reasoning_effort: body?.reasoning_effort || body?.reasoningEffort || "high",
174
+ messages: body?.messages || [],
175
+ };
176
+ const forceStream = !isStream && String(upstreamModel).startsWith("deepseek/");
177
+ if (isStream || forceStream) upstreamBody.stream = true;
178
+ for (const k of ["temperature", "top_p", "tools", "tool_choice", "stop", "presence_penalty", "frequency_penalty", "response_format", "user", "n", "seed"]) {
179
+ if (body[k] !== undefined) upstreamBody[k] = body[k];
180
+ }
181
+
182
+ // 网络层重试
183
+ for (let netAttempt = 0; netAttempt < 3; netAttempt++) {
184
+ try {
185
+ const resp = await clineFetchWithRetry(upstreamBody, sessionId, true);
186
+ if (!resp) throw new Error("empty response");
187
+ if (!resp.ok) {
188
+ // 直通错误(403/400 等)
189
+ return resp;
190
+ }
191
+ if (isStream) return resp;
192
+ if (forceStream) {
193
+ const ret = await nonStreamWithContentCheck(upstreamBody, sessionId, resp);
194
+ if (ret.error) return ret.error;
195
+ ret.data.model = model;
196
+ const hdrs = new Headers({ "Content-Type": "application/json" });
197
+ return new Response(JSON.stringify(ret.data), { status: 200, headers: hdrs });
198
+ }
199
+ // 普通非流式(非 deepseek)
200
+ const raw = await resp.json().catch(() => null);
201
+ if (!raw) return resp;
202
+ const normalized = unwrapData(raw);
203
+ normalized.model = model;
204
+ return new Response(JSON.stringify(normalized), { status: 200, headers: { "Content-Type": "application/json" } });
205
+ } catch (err) {
206
+ if (netAttempt < 2 && String(err?.message || "").toLowerCase().includes("timed out")) { await sleep(300); continue; }
207
+ throw err;
208
+ }
209
+ }
210
+ throw new Error("cline chat failed after retries");
211
+ }
212
+
213
+ return { runChat, streamToNonStream, _clineFetch: clineFetch };
214
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Cline 指纹头:官方靠这些头识别“是不是 Cline 客户端”
3
+ * 缺少即 403: "only available via Cline product surfaces"
4
+ * 逆向自 pingmike2/cline2api-workers worker.js clineHeaders
5
+ */
6
+ export function clineHeaders(sessionId, token) {
7
+ return {
8
+ Authorization: `Bearer workos:${token}`,
9
+ "Content-Type": "application/json",
10
+ "User-Agent": "Cline/3.0.47",
11
+ "HTTP-Referer": "https://cline.bot",
12
+ "X-Title": "Cline",
13
+ "X-IS-MULTIROOT": "false",
14
+ "X-CLIENT-TYPE": "cline-sdk",
15
+ "X-CLIENT-VERSION": "3.0.47",
16
+ "X-PLATFORM": "terminal",
17
+ "X-PLATFORM-VERSION": "3.0.47",
18
+ "X-CORE-VERSION": "0.0.66",
19
+ "X-Task-ID": sessionId,
20
+ };
21
+ }
22
+
23
+ export function isRefreshToken(key) {
24
+ const s = String(key || "").trim();
25
+ if (!s) return false;
26
+ // sk_ 形态直接视为旧直连 key,不走 refresh 链
27
+ if (s.startsWith("sk_")) return false;
28
+ // refreshToken 通常为 JWT 或长随机串,长度 > 20 且含 . 或 -
29
+ if (s.length > 20) return true;
30
+ return false;
31
+ }
@@ -0,0 +1,121 @@
1
+ import { createKeyRing } from "../keyring.js";
2
+ import { loadProviderKeys, loadProviderBaseUrl, loadProviderModelsPath, loadProviderChatPath, saveProviderConfig } from "../../state.js";
3
+ import { envInt, joinUrl, getUndici, createAgent, collectApiKeysGeneric, createChatRunner } from "../base.js";
4
+ import { joinModelId } from "../model-id.js";
5
+ import { createAuthPool } from "./auth.js";
6
+ import { clineHeaders, isRefreshToken } from "./headers.js";
7
+ import { createChatService } from "./chat.js";
8
+ import { createModelsService } from "./models.js";
9
+
10
+ const { UndiciFetch } = getUndici();
11
+
12
+ function resolveBaseUrl(id, baseUrl) {
13
+ if (baseUrl) return String(baseUrl).trim().replace(/\/+$/, "");
14
+ const env = loadProviderBaseUrl(id);
15
+ if (env) return env;
16
+ return "https://api.cline.bot";
17
+ }
18
+
19
+ export function createClineProvider({
20
+ id = "cline",
21
+ baseUrl,
22
+ apiKeys,
23
+ apiKey,
24
+ modelsPath,
25
+ chatPath,
26
+ connectTimeoutMs = Number(process.env.MSLXDFF_CLINE_TIMEOUT_MS) || 30_000,
27
+ cooldownMs = envInt("MSLXDFF_CLINE_COOLDOWN_MS", 30_000),
28
+ retry = { network: { attempts: 2, delayMs: 300 }, 429: { attempts: 1, delayMs: 100 }, 502: { attempts: 1, delayMs: 100 }, 503: { attempts: 1, delayMs: 100 }, 504: { attempts: 1, delayMs: 100 } },
29
+ fetchImpl,
30
+ file,
31
+ } = {}) {
32
+ const resolvedBase = resolveBaseUrl(id, baseUrl);
33
+ const _cfgModels = modelsPath ? null : loadProviderModelsPath(id, file ? { file } : {});
34
+ const resolvedModelsPath = modelsPath || (_cfgModels && _cfgModels !== "/models" ? _cfgModels : null) || "/ai/cline/recommended-models";
35
+ const defaultChat = String(resolvedBase).includes("/api/v1") ? "/chat/completions" : "/api/v1/chat/completions";
36
+ const resolvedChatPath = chatPath || loadProviderChatPath(id, file ? { file } : {}) || defaultChat;
37
+ if (!fetchImpl) fetchImpl = UndiciFetch || fetch;
38
+
39
+ const rawKeys = collectApiKeysGeneric(id, apiKeys, apiKey, (pid) => loadProviderKeys(pid, file ? { file } : {}));
40
+ // 同时兼容 cline 与 clinebot 两个 id 的 keys(用户可能配在任一)
41
+ let extraKeys = [];
42
+ try {
43
+ const altId = id === "cline" ? "clinebot" : "cline";
44
+ const alt = loadProviderKeys(altId, file ? { file } : {});
45
+ if (alt.length) extraKeys = alt;
46
+ } catch {}
47
+ const allKeys = [...new Set([...rawKeys, ...extraKeys].map((k) => String(k).trim()).filter(Boolean))];
48
+ const hasRefresh = allKeys.some((k) => isRefreshToken(k));
49
+ const ring = createKeyRing(allKeys.filter((k) => !isRefreshToken(k)), { cooldownMs });
50
+
51
+ let dispatcher = null; let agent = null;
52
+ const a = createAgent({ keepAliveTimeout: envInt("MSLXDFF_CLINE_KEEPALIVE_TIMEOUT", 30_000), keepAliveMaxTimeout: envInt("MSLXDFF_CLINE_KEEPALIVE_MAX_TIMEOUT", 60_000), connections: envInt("MSLXDFF_CLINE_KEEPALIVE_CONNECTIONS", 20) });
53
+ agent = a.agent; dispatcher = a.dispatcher;
54
+
55
+ // 旧直连模式(兼容 sk_ 匿名)
56
+ function buildHeadersOld(body, key) {
57
+ const isStream = body?.stream !== false;
58
+ return { "Content-Type": "application/json", Accept: isStream ? "text/event-stream" : "*/*", "User-Agent": "mslxdff", ...(key ? { Authorization: `Bearer ${key}` } : {}) };
59
+ }
60
+ const oldRunner = createChatRunner({ id, ring, cooldownMs, retry, fetchImpl, dispatcher, buildHeaders: buildHeadersOld, getUrl: () => joinUrl(resolvedBase, resolvedChatPath), connectTimeoutMs });
61
+
62
+ // 新 refreshToken 模式
63
+ let authPool = null; let newChatSvc = null;
64
+ if (hasRefresh) {
65
+ const refreshTokens = allKeys.filter((k) => isRefreshToken(k));
66
+ authPool = createAuthPool({
67
+ id, keys: refreshTokens, fetchImpl, dispatcher, baseUrl: resolvedBase, file,
68
+ saveFn: async ({ oldRefreshToken, newRefreshToken }) => {
69
+ try {
70
+ const cur = loadProviderKeys(id, file ? { file } : {});
71
+ const idx = cur.indexOf(oldRefreshToken);
72
+ if (idx >= 0) {
73
+ const next = [...cur]; next[idx] = newRefreshToken;
74
+ // 优先写 providerConfigs,兼容旧路径由 saveProviderConfig 处理
75
+ saveProviderConfig(id, { baseUrl: resolvedBase, keys: next }, file ? { file } : {});
76
+ }
77
+ // altId 同步
78
+ const altId = id === "cline" ? "clinebot" : "cline";
79
+ const cur2 = loadProviderKeys(altId, file ? { file } : {});
80
+ const idx2 = cur2.indexOf(oldRefreshToken);
81
+ if (idx2 >= 0) {
82
+ const next2 = [...cur2]; next2[idx2] = newRefreshToken;
83
+ saveProviderConfig(altId, { baseUrl: resolvedBase, keys: next2 }, file ? { file } : {});
84
+ }
85
+ } catch {}
86
+ },
87
+ });
88
+ newChatSvc = createChatService({ id, baseUrl: resolvedBase, chatPath: resolvedChatPath, fetchImpl, dispatcher, authPool, connectTimeoutMs, retry });
89
+ }
90
+
91
+ async function chat(body) {
92
+ if (hasRefresh && newChatSvc) return newChatSvc.runChat(body, ring, id);
93
+ return oldRunner.runChat(body, ring, `MSLXDFF_${id.toUpperCase()}_KEY`);
94
+ }
95
+
96
+ async function chatWithKeys(body, keys) {
97
+ const refreshSubset = (keys || []).filter((k) => isRefreshToken(k));
98
+ if (refreshSubset.length && hasRefresh) {
99
+ const tmpPool = createAuthPool({ id, keys: refreshSubset, fetchImpl, dispatcher, baseUrl: resolvedBase, file });
100
+ const tmpSvc = createChatService({ id, baseUrl: resolvedBase, chatPath: resolvedChatPath, fetchImpl, dispatcher, authPool: tmpPool, connectTimeoutMs, retry });
101
+ return tmpSvc.runChat(body, createKeyRing([], { cooldownMs }), "shared");
102
+ }
103
+ const tmpRing = createKeyRing((keys || []).filter((k) => !isRefreshToken(k)), { cooldownMs });
104
+ return oldRunner.runChat(body, tmpRing, "shared provider keys");
105
+ }
106
+
107
+ const modelsSvc = createModelsService({ id, baseUrl: resolvedBase, modelsPath: resolvedModelsPath, fetchImpl, dispatcher, ring, loadKeys: (pid) => loadProviderKeys(pid, file ? { file } : {}) });
108
+
109
+ async function listModels() {
110
+ const list = await modelsSvc.listModels();
111
+ // 若新模式且上游返回 free,再尝试带 token 的 models(兼容私有)
112
+ return list;
113
+ }
114
+
115
+ const { preheat } = (() => {
116
+ try { return modelsSvc; } catch { return { preheat: async () => ({ ok: false }) }; }
117
+ })();
118
+
119
+ async function close() { if (agent?.close) try { await agent.close(); } catch {} }
120
+ return { id, chat, chatWithKeys, listModels, preheat, close, agent, keyRing: ring, baseUrl: resolvedBase, _authPool: authPool };
121
+ }
@@ -0,0 +1,61 @@
1
+ import { joinUrl, getUndici } from "../base.js";
2
+ import { joinModelId } from "../model-id.js";
3
+
4
+ const { UndiciFetch } = getUndici();
5
+
6
+ function isClineBotHost(baseUrl) {
7
+ try { const u = new URL(baseUrl); return u.hostname === "api.cline.bot" || u.hostname.endsWith(".cline.bot"); } catch { return String(baseUrl).includes("cline.bot"); }
8
+ }
9
+
10
+ export function createModelsService({ id, baseUrl, modelsPath, fetchImpl, dispatcher, ring, loadKeys } = {}) {
11
+ if (!fetchImpl) fetchImpl = UndiciFetch || fetch;
12
+ const resolvedBase = String(baseUrl).trim().replace(/\/+$/, "");
13
+ const resolvedPath = modelsPath || "/ai/cline/recommended-models";
14
+ const CACHE_TTL = 10 * 60 * 1000;
15
+ let cache = null;
16
+ let fetchedAt = 0;
17
+
18
+ async function listModels() {
19
+ const now = Date.now();
20
+ if (cache && now - fetchedAt < CACHE_TTL) return cache;
21
+ const url = joinUrl(resolvedBase, resolvedPath);
22
+ const controller = new AbortController();
23
+ const timer = setTimeout(() => controller.abort(new Error(`${id} models timed out`)), 15_000);
24
+ try {
25
+ const headers = { Accept: "application/json" };
26
+ const key = (loadKeys ? loadKeys(id)[0] : null) || (ring ? ring.next() : null);
27
+ if (key && !String(key).includes(".")) headers["Authorization"] = `Bearer ${key}`;
28
+ const opts = { headers, signal: controller.signal };
29
+ if (dispatcher) opts.dispatcher = dispatcher;
30
+ const res = await fetchImpl(url, opts);
31
+ if (!res.ok) return [];
32
+ const json = await res.json().catch(() => ({}));
33
+ if (isClineBotHost(resolvedBase) && Array.isArray(json.free)) {
34
+ const out = json.free.filter((m) => m && typeof m.id === "string").map((m) => ({ ...m, id: joinModelId(id, m.id) }));
35
+ cache = out; fetchedAt = now; return out;
36
+ }
37
+ const raw = Array.isArray(json.data) ? json.data : Array.isArray(json.models) ? json.models : Array.isArray(json) ? json : [];
38
+ const out = raw.filter((m) => m && typeof m.id === "string").map((m) => ({ ...m, id: joinModelId(id, m.id) }));
39
+ cache = out; fetchedAt = now; return out;
40
+ } catch { return []; } finally { clearTimeout(timer); }
41
+ }
42
+
43
+ async function preheat() {
44
+ const url = joinUrl(resolvedBase, resolvedPath);
45
+ const t0 = performance.now();
46
+ try {
47
+ const headers = { Accept: "application/json" };
48
+ const key = (loadKeys ? loadKeys(id)[0] : null) || (ring ? ring.next() : null);
49
+ if (key && !String(key).includes(".")) headers["Authorization"] = `Bearer ${key}`;
50
+ const opts = { headers };
51
+ if (dispatcher) opts.dispatcher = dispatcher;
52
+ const res = await fetchImpl(url, opts);
53
+ try { if (res.body) await res.text().catch(() => {}); } catch {}
54
+ return { ok: res.ok, status: res.status, ms: Math.round(performance.now() - t0) };
55
+ } catch (err) {
56
+ return { ok: false, error: String(err?.message || err), ms: Math.round(performance.now() - t0) };
57
+ }
58
+ }
59
+
60
+ return { listModels, preheat };
61
+ }
@@ -1,112 +1,2 @@
1
- import { createKeyRing } from "./keyring.js";
2
- import { loadProviderKeys, loadProviderBaseUrl, loadProviderModelsPath, loadProviderChatPath } from "../state.js";
3
- import { envInt, joinUrl, getUndici, createAgent, collectApiKeysGeneric, createChatRunner, createPreheatRunner } from "./base.js";
4
- import { joinModelId } from "./model-id.js";
5
-
6
- const { UndiciFetch } = getUndici();
7
-
8
- function resolveBaseUrl(id, baseUrl) {
9
- if (baseUrl) return String(baseUrl).trim().replace(/\/+$/, "");
10
- const env = loadProviderBaseUrl(id);
11
- if (env) return env;
12
- return "https://api.cline.bot";
13
- }
14
-
15
- function isClineBotHost(baseUrl) {
16
- try {
17
- const u = new URL(baseUrl);
18
- return u.hostname === "api.cline.bot" || u.hostname.endsWith(".cline.bot");
19
- } catch { return String(baseUrl).includes("cline.bot"); }
20
- }
21
-
22
- export function createClineProvider({
23
- id = "cline",
24
- baseUrl,
25
- apiKeys,
26
- apiKey,
27
- modelsPath,
28
- chatPath,
29
- connectTimeoutMs = Number(process.env.MSLXDFF_CLINE_TIMEOUT_MS) || 30_000,
30
- cooldownMs = envInt("MSLXDFF_CLINE_COOLDOWN_MS", 30_000),
31
- retry = {
32
- network: { attempts: 2, delayMs: 300 },
33
- 429: { attempts: 1, delayMs: 100 },
34
- 502: { attempts: 1, delayMs: 100 },
35
- 503: { attempts: 1, delayMs: 100 },
36
- 504: { attempts: 1, delayMs: 100 },
37
- },
38
- fetchImpl,
39
- } = {}) {
40
- const resolvedBase = resolveBaseUrl(id, baseUrl);
41
- const resolvedModelsPath = modelsPath || loadProviderModelsPath(id) || "/ai/cline/recommended-models";
42
- // base 已含 /api/v1 时,chat 仅需 /chat/completions,否则会拼成 /api/v1/v1/...
43
- const defaultChat = String(resolvedBase).includes("/api/v1") ? "/chat/completions" : "/v1/chat/completions";
44
- const resolvedChatPath = chatPath || loadProviderChatPath(id) || defaultChat;
45
- if (!fetchImpl) fetchImpl = UndiciFetch || fetch;
46
- const ring = createKeyRing(collectApiKeysGeneric(id, apiKeys, apiKey, loadProviderKeys), { cooldownMs });
47
-
48
- let dispatcher = null;
49
- let agent = null;
50
- const a = createAgent({
51
- keepAliveTimeout: envInt("MSLXDFF_CLINE_KEEPALIVE_TIMEOUT", 30_000),
52
- keepAliveMaxTimeout: envInt("MSLXDFF_CLINE_KEEPALIVE_MAX_TIMEOUT", 60_000),
53
- connections: envInt("MSLXDFF_CLINE_KEEPALIVE_CONNECTIONS", 20),
54
- });
55
- agent = a.agent; dispatcher = a.dispatcher;
56
-
57
- function buildHeaders(body, key) {
58
- const isStream = body?.stream !== false;
59
- return {
60
- "Content-Type": "application/json",
61
- Accept: isStream ? "text/event-stream" : "*/*",
62
- "User-Agent": "mslxdff",
63
- ...(key ? { Authorization: `Bearer ${key}` } : {}),
64
- };
65
- }
66
-
67
- const { runChat } = createChatRunner({
68
- id, ring, cooldownMs, retry, fetchImpl, dispatcher, buildHeaders,
69
- getUrl: () => joinUrl(resolvedBase, resolvedChatPath),
70
- connectTimeoutMs,
71
- });
72
-
73
- async function chat(body) { return runChat(body, ring, `MSLXDFF_${id.toUpperCase()}_KEY`); }
74
- async function chatWithKeys(body, keys) {
75
- const tmp = createKeyRing(keys, { cooldownMs });
76
- return runChat(body, tmp, "shared provider keys");
77
- }
78
-
79
- async function listModels() {
80
- const url = joinUrl(resolvedBase, resolvedModelsPath);
81
- const controller = new AbortController();
82
- const timer = setTimeout(() => controller.abort(new Error(`${id} models timed out`)), 15_000);
83
- try {
84
- const headers = { Accept: "application/json" };
85
- const key = ring.next();
86
- if (key) headers["Authorization"] = `Bearer ${key}`;
87
- const opts = { headers, signal: controller.signal };
88
- if (dispatcher) opts.dispatcher = dispatcher;
89
- const res = await fetchImpl(url, opts);
90
- if (!res.ok) return [];
91
- const json = await res.json().catch(() => ({}));
92
- // 定制:cline.bot 域名时,仅取 free 数组;其他回退通用
93
- if (isClineBotHost(resolvedBase) && Array.isArray(json.free)) {
94
- return json.free.filter((m) => m && typeof m.id === "string").map((m) => ({ ...m, id: joinModelId(id, m.id) }));
95
- }
96
- const raw = Array.isArray(json.data) ? json.data : Array.isArray(json.models) ? json.models : Array.isArray(json) ? json : [];
97
- return raw.filter((m) => m && typeof m.id === "string").map((m) => ({ ...m, id: joinModelId(id, m.id) }));
98
- } catch {
99
- return [];
100
- } finally {
101
- clearTimeout(timer);
102
- }
103
- }
104
-
105
- const { preheat } = createPreheatRunner({
106
- dispatcher, fetchImpl, getUrl: () => joinUrl(resolvedBase, resolvedModelsPath),
107
- id, ring, loadKeys: loadProviderKeys,
108
- });
109
-
110
- async function close() { if (agent?.close) try { await agent.close(); } catch {} }
111
- return { id, chat, chatWithKeys, listModels, preheat, close, agent, keyRing: ring, baseUrl: resolvedBase };
112
- }
1
+ // Shim 保持历史导入路径兼容,真实实现下沉至 ./cline/ 深模块
2
+ export * from "./cline/index.js";
@@ -124,28 +124,9 @@ export function createChatGateway({ upstream, auto, logs, peers, maxHops, groups
124
124
  if (Number.isInteger(v) && v > 0) return Math.min(v, nonCoolingOrder.length);
125
125
  return Math.min(nonCoolingOrder.length, 5);
126
126
  })();
127
- const raceModels = nonCoolingOrder.slice(0, concLimit);
128
- evt("auto-concurrent-race", { reqId, models: raceModels, skippedFaulty: order.length - nonCoolingOrder.length, limit: concLimit });
129
- const raceStart = performance.now();
130
- const attempts = raceModels.map(async (m) => {
131
- const fwd = { ...injectReasoningContent(m, body), model: m };
132
- let r = null;
133
- try {
134
- const chatOpts = {};
135
- if (Object.keys(shareKeys).length) chatOpts.shareKeys = shareKeys;
136
- if (workbuddyUid) chatOpts.workbuddyUid = workbuddyUid;
137
- r = await upstream.chat(fwd, Object.keys(chatOpts).length ? chatOpts : undefined);
138
- } catch (err) {
139
- return { model: m, ok: false, error: errMsg(err), status: 502, timing: err?._t ?? null };
140
- }
141
- if (r && r.status >= 400) {
142
- const isAllow = r.status === 403 && r.headers?.get?.("x-mslxdff-allowlist") === "1";
143
- if (isAllow) return { model: m, ok: false, error: "allowlist", status: 403, allowlist: true };
144
- return { model: m, ok: false, error: `upstream ${r.status}`, status: r.status, res: r, timing: r._t ?? null };
145
- }
146
- if (r instanceof Error) return { model: m, ok: false, error: errMsg(r), status: 502 };
147
- return { model: m, ok: true, res: r, status: r.status, timing: r._t ?? null };
148
- });
127
+ let raceModels=nonCoolingOrder.slice(0,concLimit);
128
+ if(plugins?.length){const k=[];for(const m of raceModels){const b=await runHook(plugins,"model:beforeTry",{reqId,requested,model:m,hops});if(b.value===false||b.value?.skip)continue;k.push(m);}raceModels=k;}
129
+ if(!raceModels.length){order=order.filter(m=>!new Set(nonCoolingOrder.slice(0,concLimit)).has(m));if(!order.length){await handleExhaustedAll({res,body,lastErr:{model:requested,status:502,message:"all concurrent candidates skipped by plugin"},order:nonCoolingOrder.slice(0,concLimit),requested,handlerCtx:{...handlerCtx,reqId,startedAt},evt,logCall,mark,perf0,stages});return;}}else{evt("auto-concurrent-race",{reqId,models:raceModels,skippedFaulty:order.length-nonCoolingOrder.length,limit:concLimit});const raceStart=performance.now();const attempts=raceModels.map(async m=>{let f={...injectReasoningContent(m,body),model:m};if(plugins?.length){const u=await runHook(plugins,"upstream:request",{reqId,requested,model:m,payload:f,stream:Boolean(body.stream)});if(u.changed&&u.value?.payload) f=u.value.payload;}let r=null;try{const o={};if(Object.keys(shareKeys).length)o.shareKeys=shareKeys;if(workbuddyUid)o.workbuddyUid=workbuddyUid;r=await upstream.chat(f,Object.keys(o).length?o:undefined);}catch(e){if(plugins?.length)runHook(plugins,"upstream:response",{reqId,requested,model:m,status:null,ok:false,error:errMsg(e),timing:e?._t??null}).catch(()=>{});return{model:m,ok:false,error:errMsg(e),status:502,timing:e?._t??null};}if(plugins?.length)runHook(plugins,"upstream:response",{reqId,requested,model:m,status:r instanceof Error?null:r?.status??null,ok:!(r instanceof Error)&&r?r.status<400:false,error:r instanceof Error?errMsg(r):null,timing:r?._t??null}).catch(()=>{});if(r&&r.status>=400){const a=r.status===403&&r.headers?.get?.("x-mslxdff-allowlist")==="1";if(a)return{model:m,ok:false,error:"allowlist",status:403,allowlist:true};return{model:m,ok:false,error:`upstream ${r.status}`,status:r.status,res:r,timing:r._t??null};}if(r instanceof Error)return{model:m,ok:false,error:errMsg(r),status:502};return{model:m,ok:true,res:r,status:r.status,timing:r._t??null};});
149
130
  const results = await Promise.allSettled(attempts);
150
131
  const okList = results.map((r, i) => ({ r, i, model: raceModels[i] }))
151
132
  .filter(({ r }) => r.status === "fulfilled" && r.value?.ok)
@@ -185,10 +166,13 @@ export function createChatGateway({ upstream, auto, logs, peers, maxHops, groups
185
166
  const triedSet = new Set(raceModels);
186
167
  order = order.filter((m) => !triedSet.has(m));
187
168
  if (!order.length) {
188
- const last = { model: raceModels[0] || requested, status: 502, message: "all concurrent candidates failed" };
169
+ const failedStatuses = results.map((r) => (r.status === "fulfilled" ? r.value?.status : null)).filter((s) => Number.isInteger(s));
170
+ const lastStatus = failedStatuses[failedStatuses.length - 1] || failedStatuses[0] || 502;
171
+ const last = { model: raceModels[0] || requested, status: lastStatus, message: "all concurrent candidates failed" };
189
172
  await handleExhaustedAll({ res, body, lastErr: last, order: raceModels, requested, handlerCtx: { ...handlerCtx, reqId, startedAt }, evt, logCall, mark, perf0, stages });
190
173
  return;
191
174
  }
175
+ } // close else (raceModels not empty)
192
176
  }
193
177
  }
194
178