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.
@@ -1,21 +1,20 @@
1
1
  /**
2
- * Cline 白名单自动同步(上游列表 = 真相)。
2
+ * Cline 白名单自动同步(上游目录 = 真相,双向收敛)。
3
3
  *
4
- * 背景:`providerConfigs.cline.allowedModels` 是一份**静态快照**(历史由 `-provider cline free sync --yes` 一次性写入)。
5
- * 上游 `recommended-models` 会漂移——新模型上架免费通道、同一模型从 `cline-pass/` 挪到 `cline-free/`——
6
- * 而静态快照不跟着变,于是 `isModelAllowed()` 精确匹配失败,把上游明明免费可用的模型当成"未授权"拦掉
7
- * (实测 2026-09-24:`cline-free/gemini-3.8-flash`、`stealth/space-bunny-alpha`、`cline-free/mimo-v2.6-flash` 被拦,
8
- * 而 allowlist 里只有 `cline-pass/mimo-v2.6-flash`,通道不同即视为不同 id)。
9
- *
10
- * 本模块把语义改成「每次成功读取上游列表后,立即把上游 id 并进白名单」:
11
- * - **只增不减**:上游下架的 id 保留在表内(与 modelPicks 孤儿"仅标记不自动删"的既有口径一致,回收走 prune),
12
- * 避免同步动作变成删配置。
13
- * - **仅在上游成功路径调用**:内置兜底常量不是上游真相,写盘只会污染(由调用方保证,见 models.js)。
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: 只增不减 + 仅上游成功路径同步的取舍见 .agents/notes/implemented/feature/2026-09-24-cline-auto-sync-free-catalog.md
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
- * 纯逻辑(planMerge)无 IO,可单测;IO 全走注入。
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
- * 求并集:保留 current 原顺序与全部既有条目,把 upstreamIds 中缺失的追加到尾部。
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 上游模型 id(可带 provider 前缀,内部归一)
34
- * @param {(m:string, pid:string)=>string} [normalize]
35
- * @param {string} [providerId]
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 planMerge(current, upstreamIds, normalize = defaultNormalize, providerId = "") {
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 next = [...new Set(cur.map((m) => normalize(m, providerId)).filter(Boolean))];
41
- const seen = new Set(next);
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 raw of Array.isArray(upstreamIds) ? upstreamIds : []) {
44
- const norm = normalize(raw, providerId);
45
- if (!norm || seen.has(norm)) continue;
46
- seen.add(norm);
47
- next.push(norm);
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 } = planMerge(cur, ids, normalize, providerId);
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
- log(`[${providerId}] allowlist auto-sync: +${added.length} → ${next.length} total (${added.join(", ")})`);
84
- return { skipped: "", added, total: next.length };
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-09-20 实测 5 个;只有 id 与短名,无凭据)
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/deepseek-v4.1-flash", name: "deepseek-v4.1-flash" },
15
- { id: "cline-free/muse-spark-1.3-contributor", name: "muse-spark-1.3-contributor" },
16
- { id: "z-ai/glm-5.3-flash", name: "glm-5.3-flash" },
17
- { id: "cline-free/solar-pro4", name: "solar-pro4" },
18
- { id: "poolside/laguna-s-2.1:free", name: "laguna-s-2.1:free" },
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
  }