mslxdff 0.1.227 → 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/chat-pipeline/index.js +6 -2
- package/src/chat-pipeline/serial-trial.js +3 -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/cli/format.js +2 -0
- 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/routes/peer-probe.js +53 -0
- package/src/routes/peer-serial.js +56 -6
- package/src/routes/peers.js +8 -0
- package/src/runtime/raccoon-checkin.js +175 -126
|
@@ -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
|
|
|
@@ -1,147 +1,147 @@
|
|
|
1
|
-
import { appendEvent } from "../logs.js";
|
|
2
|
-
import { splitModelId, DEFAULT_PROVIDER, joinModelId } from "./model-id.js";
|
|
3
|
-
import { isModelAllowed as stateIsAllowed, loadProviderAllowedModels as stateLoadAllowed, loadProviderAllowAnyModels as stateLoadAllowAny } from "../state.js";
|
|
4
|
-
// 定制预热名单:registry customProviders 9 家 + 默认 opencode;openrouter/自配 generic 不预热(按需自拉)
|
|
5
|
-
const CUSTOM_PREHEAT_IDS = new Set(["opencode", "workbuddy", "traework", "cline", "codearts", "qoder", "globalqwenwork", "qwenwork", "zcode", "raccoon"]);
|
|
6
|
-
function isPreheatDisabled() {
|
|
7
|
-
const raw = process.env.MSLXDFF_PREHEAT;
|
|
8
|
-
if (raw === undefined || raw === null || raw === "") return false;
|
|
9
|
-
const s = String(raw).trim().toLowerCase();
|
|
10
|
-
return s === "0" || s === "off" || s === "false" || s === "no" || s === "disable" || s === "disabled";
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
// 多供应商 dispatcher:把多个 Provider 聚合成一个 `upstream` 形状(chat/preheat/close),
|
|
14
|
-
// 按 body.model 的前缀路由到对应供应商,转发上游前剥掉前缀只发原始 id。
|
|
15
|
-
// 纯化:第二参可注入纯函数 isAllowed/getAllowed/getAllowAny,便于单测不读盘
|
|
16
|
-
export function createProviderDispatcher(providers = [], opts = {}) {
|
|
17
|
-
const byId = new Map(providers.map((p) => [p.id, p]));
|
|
18
|
-
// 纯化注入:第二参可为 {isAllowed, getAllowedModels, getAllowAny} 或直接函数 isAllowed
|
|
19
|
-
let isAllowedFn, getAllowedFn, getAllowAnyFn;
|
|
20
|
-
if (typeof opts === "function") {
|
|
21
|
-
isAllowedFn = opts;
|
|
22
|
-
getAllowedFn = stateLoadAllowed;
|
|
23
|
-
getAllowAnyFn = stateLoadAllowAny;
|
|
24
|
-
} else {
|
|
25
|
-
isAllowedFn = opts.isAllowed || stateIsAllowed;
|
|
26
|
-
getAllowedFn = opts.getAllowedModels || stateLoadAllowed;
|
|
27
|
-
getAllowAnyFn = opts.getAllowAny || stateLoadAllowAny;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function resolve(model) {
|
|
31
|
-
const split = splitModelId(model, providers.map((p) => p.id));
|
|
32
|
-
return {
|
|
33
|
-
provider: byId.get(split.provider) ?? byId.get(DEFAULT_PROVIDER) ?? providers[0],
|
|
34
|
-
raw: split.raw,
|
|
35
|
-
prefixed: split.prefixed,
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
async function chat(body, opts = {}) {
|
|
40
|
-
let { provider, raw } = resolve(body?.model);
|
|
41
|
-
if (!provider) throw new Error(`no provider for model ${body?.model ?? "(empty)"}`);
|
|
42
|
-
let workbuddyUid = opts?.workbuddyUid ? String(opts.workbuddyUid).trim() : "";
|
|
43
|
-
// model 前缀钉死:workbuddy/<uid>:<rawId> -> 剥 uid,rawId 为真实模型
|
|
44
|
-
if (provider.id === "workbuddy" && typeof raw === "string" && raw.includes(":")) {
|
|
45
|
-
const idx = raw.indexOf(":");
|
|
46
|
-
const uidPart = raw.slice(0, idx).trim();
|
|
47
|
-
const modelPart = raw.slice(idx + 1).trim();
|
|
48
|
-
if (uidPart && modelPart) {
|
|
49
|
-
if (!workbuddyUid) workbuddyUid = uidPart;
|
|
50
|
-
raw = modelPart;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
if (!isAllowedFn(provider.id, raw)) {
|
|
54
|
-
const allowed = getAllowedFn(provider.id) || [];
|
|
55
|
-
const msg = `model not allowed for provider "${provider.id}": "${raw}" — allowed: ${allowed.join(", ") || "(none)"} (use: mslxdff -provider ${provider.id} allowlist add <model>)`;
|
|
56
|
-
appendEvent({ type: "provider-model-state", provider: provider.id, model: body?.model, rawModel: raw, state: "blocked", reason: "allowlist", status: 403 });
|
|
57
|
-
return new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json", "x-mslxdff-allowlist": "1" } });
|
|
58
|
-
}
|
|
59
|
-
const forwarded = raw === body?.model ? body : { ...body, model: raw };
|
|
60
|
-
// ADR-0008:本请求携带瞬时共享 key(shareKeys 由组员侧按 header 解析后传入)。
|
|
61
|
-
const sharedKeys = opts?.shareKeys?.[provider.id];
|
|
62
|
-
const startedAt = Date.now();
|
|
63
|
-
let res;
|
|
64
|
-
try {
|
|
65
|
-
if (sharedKeys && sharedKeys.length && typeof provider.chatWithKeys === "function") {
|
|
66
|
-
res = await provider.chatWithKeys(forwarded, sharedKeys, opts);
|
|
67
|
-
} else if (provider.id === "workbuddy" && workbuddyUid) {
|
|
68
|
-
res = await provider.chat(forwarded, { ...opts, workbuddyUid });
|
|
69
|
-
} else {
|
|
70
|
-
res = await provider.chat(forwarded, opts);
|
|
71
|
-
}
|
|
72
|
-
} catch (err) {
|
|
73
|
-
appendEvent({ type: "provider-model-state", provider: provider.id, model: body?.model, rawModel: raw, state: "upstream-error", error: String(err?.message || err), durationMs: Date.now() - startedAt });
|
|
74
|
-
throw err;
|
|
75
|
-
}
|
|
76
|
-
appendEvent({
|
|
77
|
-
type: "provider-model-state",
|
|
78
|
-
provider: provider.id,
|
|
79
|
-
model: body?.model,
|
|
80
|
-
rawModel: raw,
|
|
81
|
-
status: res?.status,
|
|
82
|
-
state: res?.status === 429 ? "limited" : res?.ok ? "ok" : "upstream-error",
|
|
83
|
-
durationMs: Date.now() - startedAt,
|
|
84
|
-
});
|
|
85
|
-
return res;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// 聚合所有供应商的模型列表;默认供应商(opencode)裸 id,其它带前缀
|
|
89
|
-
// 若某供应商设置了 allowlist(非空),则仅暴露白名单内的模型(按 raw id 匹配)
|
|
90
|
-
async function listModels() {
|
|
91
|
-
const out = [];
|
|
92
|
-
const seen = new Set();
|
|
93
|
-
for (const p of providers) {
|
|
94
|
-
let list;
|
|
95
|
-
try {
|
|
96
|
-
list = (await p.listModels?.()) ?? [];
|
|
97
|
-
} catch {
|
|
98
|
-
list = [];
|
|
99
|
-
}
|
|
100
|
-
const allowed = getAllowedFn(p.id) || [];
|
|
101
|
-
const allowAny = getAllowAnyFn(p.id);
|
|
102
|
-
const allowedSet = allowed.length ? new Set(allowed) : null;
|
|
103
|
-
// 空名单且不允许任意模型 => 该供应商不暴露任何模型(安全默认)
|
|
104
|
-
if (!allowedSet && !allowAny) continue;
|
|
105
|
-
for (const m of list) {
|
|
106
|
-
if (!m || !m.id) continue;
|
|
107
|
-
if (seen.has(m.id)) continue;
|
|
108
|
-
if (allowedSet) {
|
|
109
|
-
// m.id 已是带前缀的对外 id,需剥回 raw 再比对
|
|
110
|
-
const { raw } = splitModelId(m.id, providers.map((x) => x.id));
|
|
111
|
-
const rawNorm = String(raw || "").trim();
|
|
112
|
-
if (!allowedSet.has(rawNorm)) continue;
|
|
113
|
-
// 对于 opencode 裸 id,rawNorm 即 m.id 本身
|
|
114
|
-
}
|
|
115
|
-
seen.add(m.id);
|
|
116
|
-
out.push(m);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
return out;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// 预热定制供应商 + 默认供应商(opencode):registry 名单 8 家 + opencode 共 9 家并发各打一次;
|
|
123
|
-
// openrouter/用户自配 generic 不在此列(按需首次请求自拉)。单家失败静默,不拖累启动。
|
|
124
|
-
async function preheat() {
|
|
125
|
-
if (isPreheatDisabled()) return { ok: true, skipped: true };
|
|
126
|
-
const targets = providers.filter((p) => p && typeof p.preheat === "function" && CUSTOM_PREHEAT_IDS.has(p.id));
|
|
127
|
-
if (!targets.length) return { ok: false, skipped: true };
|
|
128
|
-
const settled = await Promise.all(targets.map(async (p) => {
|
|
129
|
-
try {
|
|
130
|
-
const r = await p.preheat();
|
|
131
|
-
return { id: p.id, ...(r && typeof r === "object" ? r : { ok: false }) };
|
|
132
|
-
} catch {
|
|
133
|
-
return { id: p.id, ok: false, error: "preheat failed" };
|
|
134
|
-
}
|
|
135
|
-
}));
|
|
136
|
-
const okCount = settled.filter((r) => r && r.ok).length;
|
|
137
|
-
return { ok: okCount > 0, results: settled, okCount, total: settled.length };
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
async function close() {
|
|
141
|
-
for (const p of providers) {
|
|
142
|
-
try { await p.close?.(); } catch {}
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
return { providers, byId, resolve, listModels, chat, preheat, close, joinModelId };
|
|
1
|
+
import { appendEvent } from "../logs.js";
|
|
2
|
+
import { splitModelId, DEFAULT_PROVIDER, joinModelId } from "./model-id.js";
|
|
3
|
+
import { isModelAllowed as stateIsAllowed, loadProviderAllowedModels as stateLoadAllowed, loadProviderAllowAnyModels as stateLoadAllowAny } from "../state.js";
|
|
4
|
+
// 定制预热名单:registry customProviders 9 家 + 默认 opencode;openrouter/自配 generic 不预热(按需自拉)
|
|
5
|
+
const CUSTOM_PREHEAT_IDS = new Set(["opencode", "workbuddy", "traework", "cline", "codearts", "qoder", "globalqwenwork", "qwenwork", "zcode", "raccoon", "agentrouter"]);
|
|
6
|
+
function isPreheatDisabled() {
|
|
7
|
+
const raw = process.env.MSLXDFF_PREHEAT;
|
|
8
|
+
if (raw === undefined || raw === null || raw === "") return false;
|
|
9
|
+
const s = String(raw).trim().toLowerCase();
|
|
10
|
+
return s === "0" || s === "off" || s === "false" || s === "no" || s === "disable" || s === "disabled";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// 多供应商 dispatcher:把多个 Provider 聚合成一个 `upstream` 形状(chat/preheat/close),
|
|
14
|
+
// 按 body.model 的前缀路由到对应供应商,转发上游前剥掉前缀只发原始 id。
|
|
15
|
+
// 纯化:第二参可注入纯函数 isAllowed/getAllowed/getAllowAny,便于单测不读盘
|
|
16
|
+
export function createProviderDispatcher(providers = [], opts = {}) {
|
|
17
|
+
const byId = new Map(providers.map((p) => [p.id, p]));
|
|
18
|
+
// 纯化注入:第二参可为 {isAllowed, getAllowedModels, getAllowAny} 或直接函数 isAllowed
|
|
19
|
+
let isAllowedFn, getAllowedFn, getAllowAnyFn;
|
|
20
|
+
if (typeof opts === "function") {
|
|
21
|
+
isAllowedFn = opts;
|
|
22
|
+
getAllowedFn = stateLoadAllowed;
|
|
23
|
+
getAllowAnyFn = stateLoadAllowAny;
|
|
24
|
+
} else {
|
|
25
|
+
isAllowedFn = opts.isAllowed || stateIsAllowed;
|
|
26
|
+
getAllowedFn = opts.getAllowedModels || stateLoadAllowed;
|
|
27
|
+
getAllowAnyFn = opts.getAllowAny || stateLoadAllowAny;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function resolve(model) {
|
|
31
|
+
const split = splitModelId(model, providers.map((p) => p.id));
|
|
32
|
+
return {
|
|
33
|
+
provider: byId.get(split.provider) ?? byId.get(DEFAULT_PROVIDER) ?? providers[0],
|
|
34
|
+
raw: split.raw,
|
|
35
|
+
prefixed: split.prefixed,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function chat(body, opts = {}) {
|
|
40
|
+
let { provider, raw } = resolve(body?.model);
|
|
41
|
+
if (!provider) throw new Error(`no provider for model ${body?.model ?? "(empty)"}`);
|
|
42
|
+
let workbuddyUid = opts?.workbuddyUid ? String(opts.workbuddyUid).trim() : "";
|
|
43
|
+
// model 前缀钉死:workbuddy/<uid>:<rawId> -> 剥 uid,rawId 为真实模型
|
|
44
|
+
if (provider.id === "workbuddy" && typeof raw === "string" && raw.includes(":")) {
|
|
45
|
+
const idx = raw.indexOf(":");
|
|
46
|
+
const uidPart = raw.slice(0, idx).trim();
|
|
47
|
+
const modelPart = raw.slice(idx + 1).trim();
|
|
48
|
+
if (uidPart && modelPart) {
|
|
49
|
+
if (!workbuddyUid) workbuddyUid = uidPart;
|
|
50
|
+
raw = modelPart;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (!isAllowedFn(provider.id, raw)) {
|
|
54
|
+
const allowed = getAllowedFn(provider.id) || [];
|
|
55
|
+
const msg = `model not allowed for provider "${provider.id}": "${raw}" — allowed: ${allowed.join(", ") || "(none)"} (use: mslxdff -provider ${provider.id} allowlist add <model>)`;
|
|
56
|
+
appendEvent({ type: "provider-model-state", provider: provider.id, model: body?.model, rawModel: raw, state: "blocked", reason: "allowlist", status: 403 });
|
|
57
|
+
return new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json", "x-mslxdff-allowlist": "1" } });
|
|
58
|
+
}
|
|
59
|
+
const forwarded = raw === body?.model ? body : { ...body, model: raw };
|
|
60
|
+
// ADR-0008:本请求携带瞬时共享 key(shareKeys 由组员侧按 header 解析后传入)。
|
|
61
|
+
const sharedKeys = opts?.shareKeys?.[provider.id];
|
|
62
|
+
const startedAt = Date.now();
|
|
63
|
+
let res;
|
|
64
|
+
try {
|
|
65
|
+
if (sharedKeys && sharedKeys.length && typeof provider.chatWithKeys === "function") {
|
|
66
|
+
res = await provider.chatWithKeys(forwarded, sharedKeys, opts);
|
|
67
|
+
} else if (provider.id === "workbuddy" && workbuddyUid) {
|
|
68
|
+
res = await provider.chat(forwarded, { ...opts, workbuddyUid });
|
|
69
|
+
} else {
|
|
70
|
+
res = await provider.chat(forwarded, opts);
|
|
71
|
+
}
|
|
72
|
+
} catch (err) {
|
|
73
|
+
appendEvent({ type: "provider-model-state", provider: provider.id, model: body?.model, rawModel: raw, state: "upstream-error", error: String(err?.message || err), durationMs: Date.now() - startedAt });
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
appendEvent({
|
|
77
|
+
type: "provider-model-state",
|
|
78
|
+
provider: provider.id,
|
|
79
|
+
model: body?.model,
|
|
80
|
+
rawModel: raw,
|
|
81
|
+
status: res?.status,
|
|
82
|
+
state: res?.status === 429 ? "limited" : res?.ok ? "ok" : "upstream-error",
|
|
83
|
+
durationMs: Date.now() - startedAt,
|
|
84
|
+
});
|
|
85
|
+
return res;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 聚合所有供应商的模型列表;默认供应商(opencode)裸 id,其它带前缀
|
|
89
|
+
// 若某供应商设置了 allowlist(非空),则仅暴露白名单内的模型(按 raw id 匹配)
|
|
90
|
+
async function listModels() {
|
|
91
|
+
const out = [];
|
|
92
|
+
const seen = new Set();
|
|
93
|
+
for (const p of providers) {
|
|
94
|
+
let list;
|
|
95
|
+
try {
|
|
96
|
+
list = (await p.listModels?.()) ?? [];
|
|
97
|
+
} catch {
|
|
98
|
+
list = [];
|
|
99
|
+
}
|
|
100
|
+
const allowed = getAllowedFn(p.id) || [];
|
|
101
|
+
const allowAny = getAllowAnyFn(p.id);
|
|
102
|
+
const allowedSet = allowed.length ? new Set(allowed) : null;
|
|
103
|
+
// 空名单且不允许任意模型 => 该供应商不暴露任何模型(安全默认)
|
|
104
|
+
if (!allowedSet && !allowAny) continue;
|
|
105
|
+
for (const m of list) {
|
|
106
|
+
if (!m || !m.id) continue;
|
|
107
|
+
if (seen.has(m.id)) continue;
|
|
108
|
+
if (allowedSet) {
|
|
109
|
+
// m.id 已是带前缀的对外 id,需剥回 raw 再比对
|
|
110
|
+
const { raw } = splitModelId(m.id, providers.map((x) => x.id));
|
|
111
|
+
const rawNorm = String(raw || "").trim();
|
|
112
|
+
if (!allowedSet.has(rawNorm)) continue;
|
|
113
|
+
// 对于 opencode 裸 id,rawNorm 即 m.id 本身
|
|
114
|
+
}
|
|
115
|
+
seen.add(m.id);
|
|
116
|
+
out.push(m);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 预热定制供应商 + 默认供应商(opencode):registry 名单 8 家 + opencode 共 9 家并发各打一次;
|
|
123
|
+
// openrouter/用户自配 generic 不在此列(按需首次请求自拉)。单家失败静默,不拖累启动。
|
|
124
|
+
async function preheat() {
|
|
125
|
+
if (isPreheatDisabled()) return { ok: true, skipped: true };
|
|
126
|
+
const targets = providers.filter((p) => p && typeof p.preheat === "function" && CUSTOM_PREHEAT_IDS.has(p.id));
|
|
127
|
+
if (!targets.length) return { ok: false, skipped: true };
|
|
128
|
+
const settled = await Promise.all(targets.map(async (p) => {
|
|
129
|
+
try {
|
|
130
|
+
const r = await p.preheat();
|
|
131
|
+
return { id: p.id, ...(r && typeof r === "object" ? r : { ok: false }) };
|
|
132
|
+
} catch {
|
|
133
|
+
return { id: p.id, ok: false, error: "preheat failed" };
|
|
134
|
+
}
|
|
135
|
+
}));
|
|
136
|
+
const okCount = settled.filter((r) => r && r.ok).length;
|
|
137
|
+
return { ok: okCount > 0, results: settled, okCount, total: settled.length };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function close() {
|
|
141
|
+
for (const p of providers) {
|
|
142
|
+
try { await p.close?.(); } catch {}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { providers, byId, resolve, listModels, chat, preheat, close, joinModelId };
|
|
147
147
|
}
|