mslxdff 0.1.228 → 0.1.229
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/docs/cli_help_mini.md +4 -7
- package/package.json +1 -1
- package/src/cli/commands/provider/cline-free.js +19 -9
- package/src/cli/commands/provider/index.js +1 -1
- package/src/cli/commands/provider/raccoon-quota.js +156 -109
- package/src/providers/AGENTS.md +1 -1
- package/src/providers/agentrouter.js +225 -0
- package/src/providers/base.js +164 -164
- package/src/providers/cline/allowlist-sync.js +76 -38
- package/src/providers/cline/free-catalog.js +9 -6
- package/src/providers/dispatcher.js +146 -146
- package/src/providers/raccoon/const.js +202 -180
- package/src/providers/raccoon/credits.js +285 -185
- package/src/providers/raccoon/sse.js +1 -1
- package/src/providers/registry.js +7 -0
- package/src/runtime/raccoon-checkin.js +175 -126
package/src/providers/base.js
CHANGED
|
@@ -1,164 +1,164 @@
|
|
|
1
|
-
import { createKeyRing } from "./keyring.js";
|
|
2
|
-
import { joinModelId } from "./model-id.js";
|
|
3
|
-
import { getUndici as compatGetUndici, compatFetch } from "../compat.js";
|
|
4
|
-
import { createSdkDispatch } from "../upstream-engine/sdk/dispatch.js";
|
|
5
|
-
|
|
6
|
-
const { fetch: UndiciFetch, Agent: UndiciAgent } = compatGetUndici();
|
|
7
|
-
|
|
8
|
-
export function envInt(name, fallback) {
|
|
9
|
-
const v = Number(process.env[name]);
|
|
10
|
-
return Number.isInteger(v) && v > 0 ? v : fallback;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function joinUrl(base, path) {
|
|
14
|
-
const b = String(base || "").trim().replace(/\/+$/, "");
|
|
15
|
-
const p = String(path || "").trim();
|
|
16
|
-
if (!p) return b;
|
|
17
|
-
const pp = p.startsWith("/") ? p : `/${p}`;
|
|
18
|
-
return `${b}${pp}`;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function sleep(ms) {
|
|
22
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function getUndici() {
|
|
26
|
-
return { UndiciAgent, UndiciFetch, fetch: UndiciFetch || compatFetch, Agent: UndiciAgent };
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function createAgent({ keepAliveTimeout = 30_000, keepAliveMaxTimeout = 60_000, connections = 20 } = {}) {
|
|
30
|
-
if (!UndiciAgent) return { agent: null, dispatcher: null };
|
|
31
|
-
try {
|
|
32
|
-
const agent = new UndiciAgent({ keepAliveTimeout, keepAliveMaxTimeout, connections, pipelining: 1 });
|
|
33
|
-
return { agent, dispatcher: agent };
|
|
34
|
-
} catch {
|
|
35
|
-
return { agent: null, dispatcher: null };
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function collectApiKeysGeneric(id, apiKeys, apiKey, loadKeys) {
|
|
40
|
-
const list = [
|
|
41
|
-
...(Array.isArray(apiKeys) ? apiKeys : [apiKeys].filter(Boolean)),
|
|
42
|
-
apiKey,
|
|
43
|
-
...(apiKeys === undefined && apiKey === undefined ? loadKeys(id) : []),
|
|
44
|
-
].filter((k) => typeof k === "string" && k.trim().length);
|
|
45
|
-
return [...new Set(list.map((k) => k.trim()))];
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function createChatRunner({ id, ring, cooldownMs, retry, fetchImpl, dispatcher, buildHeaders, getUrl, connectTimeoutMs = 30_000, sdkDispatch } = {}) {
|
|
49
|
-
// SDK 通道(缺省):仅流式;非流式委派原生,避免把 JSON 客户端 SSE 化。
|
|
50
|
-
const sdk = sdkDispatch === undefined ? createSdkDispatch({ id, providerName: id }) : sdkDispatch;
|
|
51
|
-
|
|
52
|
-
async function attemptOnce(url, body, key, activeRing) {
|
|
53
|
-
if (sdk?.enabled && body?.stream !== false) {
|
|
54
|
-
let r;
|
|
55
|
-
try { r = await sdk.trySdk({ url, body, headers: buildHeaders(body, key), fetchImpl }); }
|
|
56
|
-
catch (err) { return err; } // 交 runChat 的 network 重试/冷却
|
|
57
|
-
if (r) return r.status === 401 && !activeRing.size ? { __needKey: true } : r;
|
|
58
|
-
}
|
|
59
|
-
const controller = new AbortController();
|
|
60
|
-
const timer = setTimeout(() => controller.abort(new Error(`${id} timed out after ${connectTimeoutMs}ms`)), connectTimeoutMs);
|
|
61
|
-
try {
|
|
62
|
-
const opts = { method: "POST", headers: buildHeaders(body, key), body: JSON.stringify(body), signal: controller.signal };
|
|
63
|
-
if (dispatcher) opts.dispatcher = dispatcher;
|
|
64
|
-
const res = await fetchImpl(url, opts);
|
|
65
|
-
if (res.status === 401 && !activeRing.size) return { __needKey: true };
|
|
66
|
-
return res;
|
|
67
|
-
} catch (err) {
|
|
68
|
-
return err;
|
|
69
|
-
} finally {
|
|
70
|
-
clearTimeout(timer);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
async function runChat(body, activeRing, sourceKey) {
|
|
75
|
-
const url = getUrl();
|
|
76
|
-
const t0 = performance.now();
|
|
77
|
-
const attempts = [];
|
|
78
|
-
let waitMs = 0;
|
|
79
|
-
const key = activeRing.next();
|
|
80
|
-
if (!key && activeRing.size > 0) {
|
|
81
|
-
const err = new Error(`${id}: all API keys are in cooldown (last error < ${cooldownMs}ms ago) — provider temporarily unavailable`);
|
|
82
|
-
err._t = { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - t0), cooldownMs };
|
|
83
|
-
throw err;
|
|
84
|
-
}
|
|
85
|
-
for (let attempt = 0; ; attempt++) {
|
|
86
|
-
const t = performance.now();
|
|
87
|
-
const result = await attemptOnce(url, body, key, activeRing);
|
|
88
|
-
const type = result instanceof Error ? "network" : `http${result?.status}`;
|
|
89
|
-
attempts.push({ attempt, type, ms: Math.round(performance.now() - t) });
|
|
90
|
-
if (result?.__needKey) {
|
|
91
|
-
const err = new Error(`${id}: missing ${sourceKey} (chat requires a real key)`);
|
|
92
|
-
err._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
93
|
-
throw err;
|
|
94
|
-
}
|
|
95
|
-
if (result instanceof Error) {
|
|
96
|
-
const entry = retry?.network;
|
|
97
|
-
if (entry && attempt < entry.attempts) { await sleep(entry.delayMs); waitMs += entry.delayMs; continue; }
|
|
98
|
-
activeRing.onError(key);
|
|
99
|
-
result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
100
|
-
throw result;
|
|
101
|
-
}
|
|
102
|
-
const entry = retry?.[result.status];
|
|
103
|
-
if (entry && attempt < entry.attempts) { await sleep(entry.delayMs); waitMs += entry.delayMs; continue; }
|
|
104
|
-
if (result.status === 401 || result.status === 403 || result.status === 429 || result.status >= 500) activeRing.onError(key);
|
|
105
|
-
result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
106
|
-
return result;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
return { attemptOnce, runChat };
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export function createListModelsRunner({ id, ring, dispatcher, fetchImpl, getUrl, mapModel }) {
|
|
114
|
-
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
115
|
-
let cache = null;
|
|
116
|
-
let fetchedAt = 0;
|
|
117
|
-
async function listModels() {
|
|
118
|
-
const now = Date.now();
|
|
119
|
-
if (cache && now - fetchedAt < CACHE_TTL_MS) return cache;
|
|
120
|
-
const url = getUrl();
|
|
121
|
-
const controller = new AbortController();
|
|
122
|
-
const timer = setTimeout(() => controller.abort(new Error(`${id} models timed out`)), 15_000);
|
|
123
|
-
try {
|
|
124
|
-
const headers = { Accept: "application/json" };
|
|
125
|
-
const key = ring.next();
|
|
126
|
-
if (key) headers["Authorization"] = `Bearer ${key}`;
|
|
127
|
-
const opts = { headers, signal: controller.signal };
|
|
128
|
-
if (dispatcher) opts.dispatcher = dispatcher;
|
|
129
|
-
const res = await fetchImpl(url, opts);
|
|
130
|
-
if (!res.ok) return [];
|
|
131
|
-
const json = await res.json().catch(() => ({}));
|
|
132
|
-
const raw = Array.isArray(json.data) ? json.data : Array.isArray(json) ? json : [];
|
|
133
|
-
const mapped = mapModel ? mapModel(raw) : raw;
|
|
134
|
-
cache = mapped.filter((m) => m && typeof m.id === "string").map((m) => ({ ...m, id: joinModelId(id, m.id) }));
|
|
135
|
-
fetchedAt = now;
|
|
136
|
-
return cache;
|
|
137
|
-
} catch {
|
|
138
|
-
return [];
|
|
139
|
-
} finally {
|
|
140
|
-
clearTimeout(timer);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
return { listModels, _cache: () => cache };
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
export function createPreheatRunner({ dispatcher, fetchImpl, getUrl, id, ring, loadKeys }) {
|
|
147
|
-
async function preheat() {
|
|
148
|
-
const url = getUrl();
|
|
149
|
-
const t0 = performance.now();
|
|
150
|
-
try {
|
|
151
|
-
const headers = { Accept: "application/json" };
|
|
152
|
-
const key = (loadKeys ? loadKeys(id)[0] : ring.next()) || ring.next();
|
|
153
|
-
if (key) headers["Authorization"] = `Bearer ${key}`;
|
|
154
|
-
const opts = { headers };
|
|
155
|
-
if (dispatcher) opts.dispatcher = dispatcher;
|
|
156
|
-
const res = await fetchImpl(url, opts);
|
|
157
|
-
try { if (res.body) await res.text().catch(() => {}); } catch {}
|
|
158
|
-
return { ok: res.ok, status: res.status, ms: Math.round(performance.now() - t0) };
|
|
159
|
-
} catch (err) {
|
|
160
|
-
return { ok: false, error: String(err?.message || err), ms: Math.round(performance.now() - t0) };
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
return { preheat };
|
|
164
|
-
}
|
|
1
|
+
import { createKeyRing } from "./keyring.js";
|
|
2
|
+
import { joinModelId } from "./model-id.js";
|
|
3
|
+
import { getUndici as compatGetUndici, compatFetch } from "../compat.js";
|
|
4
|
+
import { createSdkDispatch } from "../upstream-engine/sdk/dispatch.js";
|
|
5
|
+
|
|
6
|
+
const { fetch: UndiciFetch, Agent: UndiciAgent } = compatGetUndici();
|
|
7
|
+
|
|
8
|
+
export function envInt(name, fallback) {
|
|
9
|
+
const v = Number(process.env[name]);
|
|
10
|
+
return Number.isInteger(v) && v > 0 ? v : fallback;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function joinUrl(base, path) {
|
|
14
|
+
const b = String(base || "").trim().replace(/\/+$/, "");
|
|
15
|
+
const p = String(path || "").trim();
|
|
16
|
+
if (!p) return b;
|
|
17
|
+
const pp = p.startsWith("/") ? p : `/${p}`;
|
|
18
|
+
return `${b}${pp}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function sleep(ms) {
|
|
22
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getUndici() {
|
|
26
|
+
return { UndiciAgent, UndiciFetch, fetch: UndiciFetch || compatFetch, Agent: UndiciAgent };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createAgent({ keepAliveTimeout = 30_000, keepAliveMaxTimeout = 60_000, connections = 20 } = {}) {
|
|
30
|
+
if (!UndiciAgent) return { agent: null, dispatcher: null };
|
|
31
|
+
try {
|
|
32
|
+
const agent = new UndiciAgent({ keepAliveTimeout, keepAliveMaxTimeout, connections, pipelining: 1 });
|
|
33
|
+
return { agent, dispatcher: agent };
|
|
34
|
+
} catch {
|
|
35
|
+
return { agent: null, dispatcher: null };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function collectApiKeysGeneric(id, apiKeys, apiKey, loadKeys) {
|
|
40
|
+
const list = [
|
|
41
|
+
...(Array.isArray(apiKeys) ? apiKeys : [apiKeys].filter(Boolean)),
|
|
42
|
+
apiKey,
|
|
43
|
+
...(apiKeys === undefined && apiKey === undefined ? loadKeys(id) : []),
|
|
44
|
+
].filter((k) => typeof k === "string" && k.trim().length);
|
|
45
|
+
return [...new Set(list.map((k) => k.trim()))];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createChatRunner({ id, ring, cooldownMs, retry, fetchImpl, dispatcher, buildHeaders, getUrl, connectTimeoutMs = 30_000, sdkDispatch } = {}) {
|
|
49
|
+
// SDK 通道(缺省):仅流式;非流式委派原生,避免把 JSON 客户端 SSE 化。
|
|
50
|
+
const sdk = sdkDispatch === undefined ? createSdkDispatch({ id, providerName: id }) : sdkDispatch;
|
|
51
|
+
|
|
52
|
+
async function attemptOnce(url, body, key, activeRing) {
|
|
53
|
+
if (sdk?.enabled && body?.stream !== false) {
|
|
54
|
+
let r;
|
|
55
|
+
try { r = await sdk.trySdk({ url, body, headers: buildHeaders(body, key), fetchImpl }); }
|
|
56
|
+
catch (err) { return err; } // 交 runChat 的 network 重试/冷却
|
|
57
|
+
if (r) return r.status === 401 && !activeRing.size ? { __needKey: true } : r;
|
|
58
|
+
}
|
|
59
|
+
const controller = new AbortController();
|
|
60
|
+
const timer = setTimeout(() => controller.abort(new Error(`${id} timed out after ${connectTimeoutMs}ms`)), connectTimeoutMs);
|
|
61
|
+
try {
|
|
62
|
+
const opts = { method: "POST", headers: buildHeaders(body, key), body: JSON.stringify(body), signal: controller.signal };
|
|
63
|
+
if (dispatcher) opts.dispatcher = dispatcher;
|
|
64
|
+
const res = await fetchImpl(url, opts);
|
|
65
|
+
if (res.status === 401 && !activeRing.size) return { __needKey: true };
|
|
66
|
+
return res;
|
|
67
|
+
} catch (err) {
|
|
68
|
+
return err;
|
|
69
|
+
} finally {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function runChat(body, activeRing, sourceKey) {
|
|
75
|
+
const url = getUrl();
|
|
76
|
+
const t0 = performance.now();
|
|
77
|
+
const attempts = [];
|
|
78
|
+
let waitMs = 0;
|
|
79
|
+
const key = activeRing.next();
|
|
80
|
+
if (!key && activeRing.size > 0) {
|
|
81
|
+
const err = new Error(`${id}: all API keys are in cooldown (last error < ${cooldownMs}ms ago) — provider temporarily unavailable`);
|
|
82
|
+
err._t = { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - t0), cooldownMs };
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
for (let attempt = 0; ; attempt++) {
|
|
86
|
+
const t = performance.now();
|
|
87
|
+
const result = await attemptOnce(url, body, key, activeRing);
|
|
88
|
+
const type = result instanceof Error ? "network" : `http${result?.status}`;
|
|
89
|
+
attempts.push({ attempt, type, ms: Math.round(performance.now() - t) });
|
|
90
|
+
if (result?.__needKey) {
|
|
91
|
+
const err = new Error(`${id}: missing ${sourceKey} (chat requires a real key)`);
|
|
92
|
+
err._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
if (result instanceof Error) {
|
|
96
|
+
const entry = retry?.network;
|
|
97
|
+
if (entry && attempt < entry.attempts) { await sleep(entry.delayMs); waitMs += entry.delayMs; continue; }
|
|
98
|
+
activeRing.onError(key);
|
|
99
|
+
result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
100
|
+
throw result;
|
|
101
|
+
}
|
|
102
|
+
const entry = retry?.[result.status];
|
|
103
|
+
if (entry && attempt < entry.attempts) { await sleep(entry.delayMs); waitMs += entry.delayMs; continue; }
|
|
104
|
+
if (result.status === 401 || result.status === 403 || result.status === 429 || result.status >= 500) activeRing.onError(key);
|
|
105
|
+
result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { attemptOnce, runChat };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function createListModelsRunner({ id, ring, dispatcher, fetchImpl, getUrl, mapModel }) {
|
|
114
|
+
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
115
|
+
let cache = null;
|
|
116
|
+
let fetchedAt = 0;
|
|
117
|
+
async function listModels() {
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
if (cache && now - fetchedAt < CACHE_TTL_MS) return cache;
|
|
120
|
+
const url = getUrl();
|
|
121
|
+
const controller = new AbortController();
|
|
122
|
+
const timer = setTimeout(() => controller.abort(new Error(`${id} models timed out`)), 15_000);
|
|
123
|
+
try {
|
|
124
|
+
const headers = { Accept: "application/json" };
|
|
125
|
+
const key = ring.next();
|
|
126
|
+
if (key) headers["Authorization"] = `Bearer ${key}`;
|
|
127
|
+
const opts = { headers, signal: controller.signal };
|
|
128
|
+
if (dispatcher) opts.dispatcher = dispatcher;
|
|
129
|
+
const res = await fetchImpl(url, opts);
|
|
130
|
+
if (!res.ok) return [];
|
|
131
|
+
const json = await res.json().catch(() => ({}));
|
|
132
|
+
const raw = Array.isArray(json.data) ? json.data : Array.isArray(json) ? json : [];
|
|
133
|
+
const mapped = mapModel ? mapModel(raw) : raw;
|
|
134
|
+
cache = mapped.filter((m) => m && typeof m.id === "string").map((m) => ({ ...m, id: joinModelId(id, m.id) }));
|
|
135
|
+
fetchedAt = now;
|
|
136
|
+
return cache;
|
|
137
|
+
} catch {
|
|
138
|
+
return [];
|
|
139
|
+
} finally {
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { listModels, _cache: () => cache };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function createPreheatRunner({ dispatcher, fetchImpl, getUrl, id, ring, loadKeys }) {
|
|
147
|
+
async function preheat() {
|
|
148
|
+
const url = getUrl();
|
|
149
|
+
const t0 = performance.now();
|
|
150
|
+
try {
|
|
151
|
+
const headers = { Accept: "application/json" };
|
|
152
|
+
const key = (loadKeys ? loadKeys(id)[0] : ring.next()) || ring.next();
|
|
153
|
+
if (key) headers["Authorization"] = `Bearer ${key}`;
|
|
154
|
+
const opts = { headers };
|
|
155
|
+
if (dispatcher) opts.dispatcher = dispatcher;
|
|
156
|
+
const res = await fetchImpl(url, opts);
|
|
157
|
+
try { if (res.body) await res.text().catch(() => {}); } catch {}
|
|
158
|
+
return { ok: res.ok, status: res.status, ms: Math.round(performance.now() - t0) };
|
|
159
|
+
} catch (err) {
|
|
160
|
+
return { ok: false, error: String(err?.message || err), ms: Math.round(performance.now() - t0) };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return { preheat };
|
|
164
|
+
}
|
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Cline
|
|
2
|
+
* Cline 白名单自动同步(上游目录 = 真相,双向收敛)。
|
|
3
3
|
*
|
|
4
|
-
* 背景:`providerConfigs.cline.allowedModels`
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* -
|
|
14
|
-
* - **无新增不写盘**:上游 10 分钟缓存内反复命中同一列表时零写放大。
|
|
4
|
+
* 背景:`providerConfigs.cline.allowedModels` 曾是静态快照,上游漂移后两种偏差都会伤人:
|
|
5
|
+
* ① 新上架/换通道的免费模型被静态快照拦成 blocked(2026-09-24 实测三例);
|
|
6
|
+
* ② 已下架的免费模型长期赖在白名单里,调用即 `404 model not found`(2026-10-21 实测 6 例)。
|
|
7
|
+
* 本模块把语义定为「**上游 free 目录即真相**」:
|
|
8
|
+
* - **双向收敛**:free 目录新增的并入,free 目录消失的移除;
|
|
9
|
+
* - **范围只有 free 通道**:`freeCatalogPrefixes`(缺省 `cline-free/…` 与其它 `-free`/`:free` 形态)
|
|
10
|
+
* 是唯一可被自动移除的命名空间。`cline-pass/*`、`meta/*`、`stealth/*`、用户手填 id 一律不动——
|
|
11
|
+
* 它们不在 free 目录里是正常的(目录接口本来只返回 free),移除会误删付费/手配模型。
|
|
12
|
+
* - **只在上游成功路径调用**:内置兜底常量不是上游真相,写盘只会污染(由调用方保证,见 models.js)。
|
|
13
|
+
* - **无变化不写盘**:10 分钟缓存内反复命中同一列表时零写放大。
|
|
15
14
|
* 关掉:`MSLXDFF_CLINE_AUTOSYNC=0`(退回纯静态白名单行为)。
|
|
16
|
-
* Note:
|
|
15
|
+
* Note: 双向收敛的取舍见 .agents/notes/implemented/feature/2026-09-24-cline-auto-sync-free-catalog.md 与 docs/adr/0067-cline-free-catalog-bidirectional-sync.md。
|
|
17
16
|
*
|
|
18
|
-
* 纯逻辑(
|
|
17
|
+
* 纯逻辑(planSync)无 IO,可单测;IO 全走注入。
|
|
19
18
|
*/
|
|
20
19
|
|
|
21
20
|
/** 与 state/provider-config.js 的 normalizeAllowedModel 对齐:只剥首段等于 providerId 的前缀。调用方可注入同名函数保证单一真相。 */
|
|
@@ -28,25 +27,59 @@ export function defaultNormalize(model, providerId) {
|
|
|
28
27
|
}
|
|
29
28
|
|
|
30
29
|
/**
|
|
31
|
-
*
|
|
30
|
+
* free 通道命名空间判据(裸 id):`cline-free/<x>`、任意前缀下的 `*-free`、任意 `:free` 变体。
|
|
31
|
+
* 只有落在这个集合里的既有条目才允许被自动移除;目录里出现的新 id 不设限(上游说免费即免费)。
|
|
32
|
+
*/
|
|
33
|
+
export function isFreeChannelId(id) {
|
|
34
|
+
const s = String(id || "").trim().toLowerCase();
|
|
35
|
+
if (!s) return false;
|
|
36
|
+
if (s.startsWith("cline-free/")) return true;
|
|
37
|
+
if (s.includes(":free")) return true;
|
|
38
|
+
if (s.endsWith("-free") || s.endsWith("_free")) return true;
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 求新白名单:先剔掉「属于 free 通道但已不在上游目录」的条目,再把上游新 id 追加到尾部。
|
|
32
44
|
* @param {string[]} current 当前白名单(裸 id)
|
|
33
|
-
* @param {string[]} upstreamIds
|
|
34
|
-
* @param {
|
|
35
|
-
* @
|
|
36
|
-
* @returns {{next:string[], added:string[]}}
|
|
45
|
+
* @param {string[]} upstreamIds 上游 free 目录 id(可带 provider 前缀,内部归一)
|
|
46
|
+
* @param {{normalize?:Function, providerId?:string, isFreeChannel?:(bare:string)=>boolean}} [opts]
|
|
47
|
+
* @returns {{next:string[], added:string[], removed:string[]}}
|
|
37
48
|
*/
|
|
38
|
-
export function
|
|
49
|
+
export function planSync(current, upstreamIds, opts = {}) {
|
|
50
|
+
const { normalize = defaultNormalize, providerId = "", isFreeChannel = isFreeChannelId } = opts;
|
|
39
51
|
const cur = Array.isArray(current) ? current : [];
|
|
40
|
-
const
|
|
41
|
-
|
|
52
|
+
const ups = (Array.isArray(upstreamIds) ? upstreamIds : [])
|
|
53
|
+
.map((raw) => normalize(raw, providerId))
|
|
54
|
+
.filter(Boolean);
|
|
55
|
+
const upstream = new Set(ups);
|
|
56
|
+
const next = [];
|
|
57
|
+
const seen = new Set();
|
|
58
|
+
const removed = [];
|
|
59
|
+
for (const raw of cur) {
|
|
60
|
+
const bare = normalize(raw, providerId);
|
|
61
|
+
if (!bare) continue;
|
|
62
|
+
if (seen.has(bare)) continue;
|
|
63
|
+
if (isFreeChannel(bare) && !upstream.has(bare)) {
|
|
64
|
+
removed.push(bare);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
seen.add(bare);
|
|
68
|
+
next.push(bare);
|
|
69
|
+
}
|
|
42
70
|
const added = [];
|
|
43
|
-
for (const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
added.push(norm);
|
|
71
|
+
for (const bare of ups) {
|
|
72
|
+
if (seen.has(bare)) continue;
|
|
73
|
+
seen.add(bare);
|
|
74
|
+
next.push(bare);
|
|
75
|
+
added.push(bare);
|
|
49
76
|
}
|
|
77
|
+
return { next, added, removed };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 兼容旧名:只并集不改动既有条目(含 free 下架项)。保留导出供历史调用方/测试使用。 */
|
|
81
|
+
export function planMerge(current, upstreamIds, normalize = defaultNormalize, providerId = "") {
|
|
82
|
+
const { next, added } = planSync(current, upstreamIds, { normalize, providerId, isFreeChannel: () => false });
|
|
50
83
|
return { next, added };
|
|
51
84
|
}
|
|
52
85
|
|
|
@@ -57,9 +90,10 @@ export function planMerge(current, upstreamIds, normalize = defaultNormalize, pr
|
|
|
57
90
|
* @param {(id:string, list:string[]) => unknown} opts.persist 写回白名单
|
|
58
91
|
* @param {(msg:string) => void} [opts.onLog]
|
|
59
92
|
* @param {(m:string, pid:string)=>string} [opts.normalize]
|
|
93
|
+
* @param {(bare:string)=>boolean} [opts.isFreeChannel]
|
|
60
94
|
* @param {boolean} [opts.enabled] 显式覆盖 env 开关
|
|
61
95
|
*/
|
|
62
|
-
export function createAllowlistSync({ providerId, loadCurrent, persist, onLog, normalize = defaultNormalize, enabled } = {}) {
|
|
96
|
+
export function createAllowlistSync({ providerId, loadCurrent, persist, onLog, normalize = defaultNormalize, isFreeChannel = isFreeChannelId, enabled } = {}) {
|
|
63
97
|
const isEnabled = () => {
|
|
64
98
|
if (typeof enabled === "boolean") return enabled;
|
|
65
99
|
return String(process.env.MSLXDFF_CLINE_AUTOSYNC ?? "") !== "0";
|
|
@@ -67,25 +101,29 @@ export function createAllowlistSync({ providerId, loadCurrent, persist, onLog, n
|
|
|
67
101
|
const log = (msg) => { try { onLog?.(msg); } catch {} };
|
|
68
102
|
|
|
69
103
|
/**
|
|
70
|
-
*
|
|
104
|
+
* 把一批上游 free 模型同步进白名单(新增并入、free 通道下架移除)。
|
|
71
105
|
* @param {{id?:string}[]|string[]} models 上游模型对象数组或 id 数组
|
|
72
|
-
* @returns {{skipped:string, added:string[], total:number}}
|
|
106
|
+
* @returns {{skipped:string, added:string[], removed:string[], total:number}}
|
|
73
107
|
*/
|
|
74
108
|
function syncIds(models) {
|
|
75
|
-
if (!isEnabled()) return { skipped: "disabled", added: [], total: 0 };
|
|
76
|
-
if (!Array.isArray(models) || !models.length) return { skipped: "empty", added: [], total: 0 };
|
|
109
|
+
if (!isEnabled()) return { skipped: "disabled", added: [], removed: [], total: 0 };
|
|
110
|
+
if (!Array.isArray(models) || !models.length) return { skipped: "empty", added: [], removed: [], total: 0 };
|
|
77
111
|
try {
|
|
78
112
|
const ids = models.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
|
|
79
113
|
const cur = loadCurrent() || [];
|
|
80
|
-
const { next, added } =
|
|
81
|
-
if (!added.length) return { skipped: "", added: [], total: next.length };
|
|
114
|
+
const { next, added, removed } = planSync(cur, ids, { normalize, providerId, isFreeChannel });
|
|
115
|
+
if (!added.length && !removed.length) return { skipped: "", added: [], removed: [], total: next.length };
|
|
82
116
|
persist(providerId, next);
|
|
83
|
-
|
|
84
|
-
|
|
117
|
+
const parts = [];
|
|
118
|
+
if (added.length) parts.push(`+${added.length}`);
|
|
119
|
+
if (removed.length) parts.push(`-${removed.length}`);
|
|
120
|
+
log(`[${providerId}] allowlist auto-sync: ${parts.join("/")} → ${next.length} total${
|
|
121
|
+
added.length ? ` (added: ${added.join(", ")})` : ""}${removed.length ? ` (removed: ${removed.join(", ")})` : ""}`);
|
|
122
|
+
return { skipped: "", added, removed, total: next.length };
|
|
85
123
|
} catch (err) {
|
|
86
124
|
// 同步是增益动作,失败绝不打断模型列表读取与请求链路
|
|
87
125
|
log(`[${providerId}] allowlist auto-sync failed: ${String(err?.message || err)}`);
|
|
88
|
-
return { skipped: "error", added: [], total: 0 };
|
|
126
|
+
return { skipped: "error", added: [], removed: [], total: 0 };
|
|
89
127
|
}
|
|
90
128
|
}
|
|
91
129
|
|
|
@@ -9,13 +9,16 @@ import { joinUrl } from "../base.js";
|
|
|
9
9
|
export const FREE_CATALOG_PATH = "/api/v1/ai/cline/recommended-models";
|
|
10
10
|
export const DEFAULT_CLINE_BASE = "https://api.cline.bot";
|
|
11
11
|
|
|
12
|
-
// 上游不可达时的内置兜底(2026-
|
|
12
|
+
// 上游不可达时的内置兜底(2026-10-21 实测 6 个;只有 id 与短名,无凭据)。
|
|
13
|
+
// 旧兜底 5 个里 deepseek-v4.1-flash/solar-pro4/muse-spark-1.3-contributor/glm-5.3-flash 已下架或转付费,
|
|
14
|
+
// 现网实测 404 model not found / 402 insufficient_credits,不得再兜。
|
|
13
15
|
export const FALLBACK_FREE = Object.freeze([
|
|
14
|
-
{ id: "cline-free/
|
|
15
|
-
{ id: "cline-free/
|
|
16
|
-
{ id: "
|
|
17
|
-
{ id: "
|
|
18
|
-
{ id: "
|
|
16
|
+
{ id: "cline-free/minimax-m3.1-flash", name: "minimax-m3.1-flash" },
|
|
17
|
+
{ id: "cline-free/solar-mini4", name: "solar-mini4" },
|
|
18
|
+
{ id: "cline-free/nemotron-3.5-super-vl", name: "nemotron-3.5-super-vl" },
|
|
19
|
+
{ id: "reflection/beam-free", name: "beam-free" },
|
|
20
|
+
{ id: "nvidia/nemotron-3.5-lightning-free", name: "nemotron-3.5-lightning-free" },
|
|
21
|
+
{ id: "poolside/laguna-s-2.1-free", name: "laguna-s-2.1-free" },
|
|
19
22
|
]);
|
|
20
23
|
export const FALLBACK_FREE_IDS = FALLBACK_FREE.map((m) => m.id);
|
|
21
24
|
|