mslxdff 0.1.150 → 0.1.152

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.150",
3
+ "version": "0.1.152",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,6 +10,20 @@ import { handleBroadbandRelay } from "../routes/chat/broadband-handler.js";
10
10
  import { handleViaRoute } from "../routes/chat/via-route-handler.js";
11
11
  import { handleExhaustedLocal, handleExhaustedAll } from "../routes/chat/exhausted-handler.js";
12
12
  import { shouldUseGroupForModel, isHardLocalOnly, isKeyProviderDirectOnly } from "../state/schemas/use-group.js";
13
+ import { isEmptyTurnError } from "../routes/chat/relay-pipeline.js";
14
+
15
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
16
+
17
+ // 空转重试档位(每次请求读取 env,单测可覆盖):默认同模型最多重试 2 次、间隔 1s;
18
+ // MSLXDFF_EMPTY_TURN_RETRIES=0 关闭(回旧行为:空转直接换候选/终结)。
19
+ function emptyRetryCfg() {
20
+ const r = Number(process.env.MSLXDFF_EMPTY_TURN_RETRIES);
21
+ const d = Number(process.env.MSLXDFF_EMPTY_TURN_RETRY_DELAY_MS);
22
+ return {
23
+ max: Number.isInteger(r) && r >= 0 ? r : 2,
24
+ delayMs: Number.isFinite(d) && d >= 0 ? d : 1000,
25
+ };
26
+ }
13
27
 
14
28
  function groupSkipReason(model) {
15
29
  if (isHardLocalOnly(model)) return "provider local-only(禁组员,仅本机直连)";
@@ -54,7 +68,7 @@ export async function runSerialTrial(ctx, deps = {}) {
54
68
  }
55
69
 
56
70
  let lastErr = viaRouteLastErr;
57
- for (let idx = 0; idx < order.length; idx++) {
71
+ candidate: for (let idx = 0; idx < order.length; idx++) {
58
72
  const model = order[idx];
59
73
  handlerCtx.model = model;
60
74
  handlerCtx.orderLen = order.length;
@@ -72,82 +86,99 @@ export async function runSerialTrial(ctx, deps = {}) {
72
86
  for (const e of ur.errors) evt("plugin-hook-error", { reqId, hook: "upstream:request", plugin: e.plugin, error: e.error });
73
87
  if (ur.changed && ur.value?.payload && typeof ur.value.payload === "object") { forwarded = ur.value.payload; evt("plugin-hook", { reqId, hook: "upstream:request", applied: true, model, rewrittenModel: forwarded.model ?? null }); }
74
88
  }
75
- const tUp = performance.now();
76
- evt("upstream-try", { reqId, model, attempt: idx + 1 });
77
- try {
78
- const chatOpts = {};
79
- if (Object.keys(shareKeys).length) chatOpts.shareKeys = shareKeys;
80
- if (workbuddyUid) chatOpts.workbuddyUid = workbuddyUid;
81
- if (handlerCtx?.sessionId) chatOpts.sessionId = handlerCtx.sessionId;
82
- upRes = await upstream.chat(forwarded, Object.keys(chatOpts).length ? chatOpts : undefined);
83
- evt("upstream-done", { reqId, model, ok: !(upRes instanceof Error) && upRes.status < 400, status: upRes instanceof Error ? null : upRes.status, timing: upRes._t ?? null, error: null });
84
- } catch (err) {
85
- if (auto) await auto.recordError(model, { message: errMsg(err) });
86
- lastErr = { model, upstream: null, status: 502, message: errMsg(err) };
87
- logError(model, 502, errMsg(err));
88
- evt("upstream-error", { reqId, model, status: 502, message: errMsg(err), timing: err._t ?? { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - tUp) } });
89
- }
90
- if (plugins?.length) {
91
- runHook(plugins, "upstream:response", {
92
- reqId, requested, model,
93
- status: upRes instanceof Error ? null : upRes instanceof Object ? (upRes.status ?? null) : null,
94
- ok: !(upRes instanceof Error) && upRes ? upRes.status < 400 : false,
95
- error: upRes instanceof Error ? errMsg(upRes) : null,
96
- timing: upRes?._t ?? null,
97
- }).catch(() => {});
98
- }
99
- mark(`up-${model}`);
100
- if (upRes && upRes.status >= 400) {
101
- const isAllowlistBlock = upRes.status === 403 && (upRes.headers?.get?.("x-mslxdff-allowlist") === "1");
102
- if (isAllowlistBlock) {
103
- let bodyText = null; try { bodyText = await upRes.clone().text(); } catch {}
104
- let errBody = { error: `model not allowed for provider` };
105
- try { errBody = bodyText ? JSON.parse(bodyText) : errBody; } catch { errBody = { error: bodyText || "model not allowed" }; }
106
- if (useAuto) {
89
+ const chatOpts = {};
90
+ if (Object.keys(shareKeys).length) chatOpts.shareKeys = shareKeys;
91
+ if (workbuddyUid) chatOpts.workbuddyUid = workbuddyUid;
92
+ if (handlerCtx?.sessionId) chatOpts.sessionId = handlerCtx.sessionId;
93
+ const chatOptsArg = Object.keys(chatOpts).length ? chatOpts : undefined;
94
+ // 空转 200(模型无输出)同模型暂停重试:默认 2 次、间隔 1s;仅 EMPTY_MODEL_RESPONSE,
95
+ // 429/403/500 与 fetch 异常走原有切号/failover(防烧额度)。MSLXDFF_EMPTY_TURN_RETRIES=0 关闭。
96
+ const emptyCfg = emptyRetryCfg();
97
+ let emptyRetried = 0;
98
+ for (;;) {
99
+ const tUp = performance.now();
100
+ evt("upstream-try", { reqId, model, attempt: idx + 1, emptyRetry: emptyRetried });
101
+ try {
102
+ upRes = await upstream.chat(forwarded, chatOptsArg);
103
+ evt("upstream-done", { reqId, model, ok: !(upRes instanceof Error) && upRes.status < 400, status: upRes instanceof Error ? null : upRes.status, timing: upRes._t ?? null, error: null });
104
+ } catch (err) {
105
+ if (auto) await auto.recordError(model, { message: errMsg(err) });
106
+ lastErr = { model, upstream: null, status: 502, message: errMsg(err) };
107
+ logError(model, 502, errMsg(err));
108
+ evt("upstream-error", { reqId, model, status: 502, message: errMsg(err), timing: err._t ?? { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - tUp) } });
109
+ upRes = null;
110
+ }
111
+ if (plugins?.length) {
112
+ runHook(plugins, "upstream:response", {
113
+ reqId, requested, model,
114
+ status: upRes instanceof Error ? null : upRes instanceof Object ? (upRes.status ?? null) : null,
115
+ ok: !(upRes instanceof Error) && upRes ? upRes.status < 400 : false,
116
+ error: upRes instanceof Error ? errMsg(upRes) : null,
117
+ timing: upRes?._t ?? null,
118
+ }).catch(() => {});
119
+ }
120
+ mark(`up-${model}`);
121
+ if (upRes && upRes.status >= 400) {
122
+ const isAllowlistBlock = upRes.status === 403 && (upRes.headers?.get?.("x-mslxdff-allowlist") === "1");
123
+ if (isAllowlistBlock) {
124
+ let bodyText = null; try { bodyText = await upRes.clone().text(); } catch {}
125
+ let errBody = { error: `model not allowed for provider` };
126
+ try { errBody = bodyText ? JSON.parse(bodyText) : errBody; } catch { errBody = { error: bodyText || "model not allowed" }; }
127
+ if (useAuto) {
128
+ logError(model, 403, errBody.error || "model not allowed");
129
+ evt("upstream-error", { reqId, model, status: 403, message: errBody.error, timing: upRes._t ?? null, allowlist: true, skipped: true });
130
+ lastErr = { model, upstream: upRes, status: 403, message: errBody.error || "model not allowed" };
131
+ if (canFallback && idx < order.length - 1) { evt("fallback", { reqId, from: model, to: order[idx + 1] ?? null, reason: `allowlist skip ${errBody.error || "blocked"}` }); continue candidate; }
132
+ return json(res, 403, errBody);
133
+ }
107
134
  logError(model, 403, errBody.error || "model not allowed");
108
- evt("upstream-error", { reqId, model, status: 403, message: errBody.error, timing: upRes._t ?? null, allowlist: true, skipped: true });
109
- lastErr = { model, upstream: upRes, status: 403, message: errBody.error || "model not allowed" };
110
- if (canFallback && idx < order.length - 1) { evt("fallback", { reqId, from: model, to: order[idx + 1] ?? null, reason: `allowlist skip ${errBody.error || "blocked"}` }); continue; }
135
+ evt("upstream-error", { reqId, model, status: 403, message: errBody.error, timing: upRes._t ?? null, allowlist: true });
111
136
  return json(res, 403, errBody);
112
137
  }
113
- logError(model, 403, errBody.error || "model not allowed");
114
- evt("upstream-error", { reqId, model, status: 403, message: errBody.error, timing: upRes._t ?? null, allowlist: true });
115
- return json(res, 403, errBody);
116
- }
117
- if (auto) await auto.recordError(model, { status: upRes.status });
118
- // 读失败响应体(clone 不影响后续 relay 转发原响应;1s 上限防流式错误体拖慢)
119
- let upBody = "";
120
- try {
121
- upBody = String(await Promise.race([
122
- upRes.clone().text(),
123
- new Promise((r) => { const t = setTimeout(() => r(""), 1000); t.unref?.(); }),
124
- ])).replace(/\s+/g, " ").slice(0, 400);
125
- } catch {}
126
- const upMsg = upBody || `upstream ${upRes.status}`;
127
- lastErr = { model, upstream: upRes, status: upRes.status, message: upMsg };
128
- logError(model, upRes.status, `upstream ${upRes.status}${upBody ? ` body=${upBody.slice(0, 300)}` : ""}`);
129
- evt("upstream-error", { reqId, model, status: upRes.status, message: upMsg.slice(0, 300), timing: upRes._t ?? null });
130
- upRes = null;
131
- }
132
- if (upRes) {
133
- const isStream = Boolean(body.stream);
134
- const d = hedgeDelayMs();
135
- const hasPeers = Boolean(peers) && peers.ordered().length > 0;
136
- const canUseGroup = shouldUseGroupForModel(model);
137
- const doHedge = canUseGroup && shouldHedge({ isStream, canForwardPeers, hedgeDelayMs: d, hasPeers, model }) && upRes.status === 200 && upRes.body;
138
- if (doHedge) {
139
- const hr = await hedge({ upRes, model, body, order, idx, lastErr, requested, useAuto, lockModel, auto, peers, handlerCtx, evt, logCall, logError, mark, perf0, stages, startedAt, plugins, res, hedgeDelayMs: d });
140
- if (hr.handled) return { done: true };
141
- if (hr.lastErr) lastErr = hr.lastErr;
142
- if (hr.upRes === null) upRes = null;
143
- else if (hr.upRes) upRes = hr.upRes;
138
+ if (auto) await auto.recordError(model, { status: upRes.status });
139
+ // 读失败响应体(clone 不影响后续 relay 转发原响应;1s 上限防流式错误体拖慢)
140
+ let upBody = "";
141
+ try {
142
+ upBody = String(await Promise.race([
143
+ upRes.clone().text(),
144
+ new Promise((r) => { const t = setTimeout(() => r(""), 1000); t.unref?.(); }),
145
+ ])).replace(/\s+/g, " ").slice(0, 400);
146
+ } catch {}
147
+ const upMsg = upBody || `upstream ${upRes.status}`;
148
+ lastErr = { model, upstream: upRes, status: upRes.status, message: upMsg };
149
+ logError(model, upRes.status, `upstream ${upRes.status}${upBody ? ` body=${upBody.slice(0, 300)}` : ""}`);
150
+ evt("upstream-error", { reqId, model, status: upRes.status, message: upMsg.slice(0, 300), timing: upRes._t ?? null });
151
+ upRes = null;
152
+ break;
144
153
  }
145
154
  if (upRes) {
146
- const lr = await localRelay({ upRes, model, body, order, idx, lastErr, requested, useAuto, lockModel, auto, handlerCtx, evt, logCall, logError, mark, perf0, stages, startedAt, plugins, res });
147
- if (lr.handled) return { done: true };
148
- if (lr.lastErr) { lastErr = lr.lastErr; continue; }
149
- return { done: true };
155
+ const isStream = Boolean(body.stream);
156
+ const d = hedgeDelayMs();
157
+ const hasPeers = Boolean(peers) && peers.ordered().length > 0;
158
+ const canUseGroup = shouldUseGroupForModel(model);
159
+ const doHedge = canUseGroup && shouldHedge({ isStream, canForwardPeers, hedgeDelayMs: d, hasPeers, model }) && upRes.status === 200 && upRes.body;
160
+ if (doHedge) {
161
+ const hr = await hedge({ upRes, model, body, order, idx, lastErr, requested, useAuto, lockModel, auto, peers, handlerCtx, evt, logCall, logError, mark, perf0, stages, startedAt, plugins, res, hedgeDelayMs: d });
162
+ if (hr.handled) return { done: true };
163
+ if (hr.lastErr) lastErr = hr.lastErr;
164
+ if (hr.upRes === null) upRes = null;
165
+ else if (hr.upRes) upRes = hr.upRes;
166
+ }
167
+ if (upRes) {
168
+ const lr = await localRelay({ upRes, model, body, order, idx, lastErr, requested, useAuto, lockModel, auto, handlerCtx, evt, logCall, logError, mark, perf0, stages, startedAt, plugins, res });
169
+ if (lr.handled) return { done: true };
170
+ if (lr.lastErr && isEmptyTurnError(lr.lastErr) && emptyRetried < emptyCfg.max) {
171
+ emptyRetried++;
172
+ evt("empty-turn-retry", { reqId, model, retry: emptyRetried, max: emptyCfg.max, delayMs: emptyCfg.delayMs });
173
+ await sleep(emptyCfg.delayMs);
174
+ continue;
175
+ }
176
+ if (lr.lastErr) { lastErr = lr.lastErr; continue candidate; }
177
+ return { done: true };
178
+ }
179
+ break;
150
180
  }
181
+ break;
151
182
  }
152
183
  if (canForwardPeers) {
153
184
  if (!shouldUseGroupForModel(model)) {
@@ -49,7 +49,10 @@ export async function handleClineFree(id, sub, rest = [], args = []) {
49
49
  const cat = await loadCatalog();
50
50
  const current = loadProviderAllowedModels("cline");
51
51
  const plan = computeNext(current, cat.ids, keepExtra);
52
- const dryRun = !(isSync && yes);
52
+ // 兜底常量不是上游真相(与 daemon auto-sync 同一口径,ADR-0033):上游不可达时拒绝写盘,
53
+ // 否则会把内置 FALLBACK_FREE 落成白名单,且 full-replace 还会挤掉 auto-sync 并入的 clinePass 条目。
54
+ const fallbackRefused = isSync && yes && cat.source === "fallback";
55
+ const dryRun = !(isSync && yes) || fallbackRefused;
53
56
  let written = [];
54
57
  if (isSync && !dryRun) {
55
58
  try {
@@ -85,7 +88,9 @@ export async function handleClineFree(id, sub, rest = [], args = []) {
85
88
  if (!isSync) {
86
89
  console.log(`\n预览(只读)。一键同步:mslxdff -provider cline free sync --yes · 只增不删:加 --keep-extra`);
87
90
  } else if (dryRun) {
88
- console.log(`\n预览模式,未写入。执行:mslxdff -provider cline free sync --yes${keepExtra ? " --keep-extra" : ""}`);
91
+ console.log(fallbackRefused
92
+ ? `\n⛔ 上游目录不可达(${cat.error || cat.status}),已拒绝把内置兜底目录写进白名单——兜底常量不是上游真相(与 daemon auto-sync 同一口径)。待上游恢复后重试;确需手填用:mslxdff -provider cline allowlist set <id...>`
93
+ : `\n预览模式,未写入。执行:mslxdff -provider cline free sync --yes${keepExtra ? " --keep-extra" : ""}`);
89
94
  } else {
90
95
  console.log(`\n✅ 已写入 allowlist:${written.length} 个(providerConfigs.cline.allowedModels)`);
91
96
  console.log(` 生效:mslxdff -restart(daemon 的 /v1/models 聚合有 10 分钟缓存,重启即刷新)`);
@@ -0,0 +1,65 @@
1
+ /**
2
+ * mslxdff -provider cline quota [--json] [--account <hash>] [--model <substr>]
3
+ * 只读:聚合 cline-usage.jsonl 的账号×模型双口径统计,不碰转发/daemon/凭据。
4
+ * free 口径:本周期 tokens + 已完成周期数/上周期产出;pass 口径:近 24h + 累计。
5
+ * 空账本给空状态引导;--json 供脚本消费。
6
+ */
7
+ import { normalizeProviderId } from "../../../providers/model-id.js";
8
+ import { loadAndAggregate } from "../../../providers/cline/usage.js";
9
+
10
+ function pickOpt(args, name) {
11
+ const i = args.indexOf(name);
12
+ if (i < 0) return "";
13
+ const v = args[i + 1];
14
+ return v && !String(v).startsWith("-") ? String(v) : "";
15
+ }
16
+
17
+ /** 纯渲染:entries 为 aggregateUsage 的 value 数组(可单测,不碰 IO)。 */
18
+ export function formatQuota(entries) {
19
+ const list = [...(entries || [])];
20
+ if (!list.length) {
21
+ return [
22
+ "cline quota:账本为空(cline-usage.jsonl 无记录)。",
23
+ "先用 cline 模型成功跑一轮对话(流式/非流式都会记账),再来看:",
24
+ " mslxdff -provider cline quota",
25
+ ].join("\n");
26
+ }
27
+ const byAcct = new Map();
28
+ for (const e of list) {
29
+ if (!byAcct.has(e.accountId)) byAcct.set(e.accountId, []);
30
+ byAcct.get(e.accountId).push(e);
31
+ }
32
+ const lines = [`cline quota:${byAcct.size} 个账号 · ${list.length} 条账号×模型`];
33
+ for (const [acct, rows] of byAcct) {
34
+ lines.push(`\n[${acct}]`);
35
+ for (const r of rows.sort((a, b) => String(a.model).localeCompare(String(b.model)))) {
36
+ if (r.modelType === "free") {
37
+ lines.push(` ${r.model}(free):本周期 ${r.currentCycleTokens} · 已完成 ${r.completedCycles} 周期(上周期 ${r.lastCompletedCycleTokens})· 累计 ${r.totalTokens}`);
38
+ } else {
39
+ lines.push(` ${r.model}(pass):近24h ${r.last24hTokens} · 累计 ${r.totalTokens}`);
40
+ }
41
+ }
42
+ }
43
+ return lines.join("\n");
44
+ }
45
+
46
+ export async function handleClineQuota(id, sub, rest = [], args = []) {
47
+ if (normalizeProviderId(id) !== "cline") return false;
48
+ if (String(sub || "").toLowerCase() !== "quota") return false;
49
+ const wantsJson = args.includes("--json") || args.includes("-json");
50
+ const acctFilter = pickOpt(args, "--account");
51
+ const modelFilter = pickOpt(args, "--model");
52
+ const map = await loadAndAggregate();
53
+ let entries = [...map.values()];
54
+ if (acctFilter) entries = entries.filter((e) => e.accountId === acctFilter);
55
+ if (modelFilter) entries = entries.filter((e) => String(e.model || "").includes(modelFilter));
56
+ if (wantsJson) {
57
+ console.log(JSON.stringify({ provider: "cline", entries }, null, 2));
58
+ process.exit(0);
59
+ }
60
+ console.log(formatQuota(entries));
61
+ if (!entries.length && map.size > 0) {
62
+ console.log("\n(过滤条件无命中,去掉 --account/--model 再试)");
63
+ }
64
+ process.exit(0);
65
+ }
@@ -89,6 +89,8 @@ const { handleQoderLogin } = await import("./qoder-login.js");
89
89
  if (await handleProviderBench(id, sub, rest, args)) return true;
90
90
  const { handleClineFree } = await import("./cline-free.js");
91
91
  if (await handleClineFree(id, sub, rest, args)) return true;
92
+ const { handleClineQuota } = await import("./cline-quota.js");
93
+ if (await handleClineQuota(id, sub, rest, args)) return true;
92
94
  if (normalizeProviderId(id) === "cline" && sub === "migrate") {
93
95
  const { runStateMigrations } = await import("../../../state/migrations.js");
94
96
  const dryRun = args.includes("--dry-run") || args.includes("--dryrun");
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Cline 白名单自动同步(上游列表 = 真相)。
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 分钟缓存内反复命中同一列表时零写放大。
15
+ * 关掉:`MSLXDFF_CLINE_AUTOSYNC=0`(退回纯静态白名单行为)。
16
+ * Note: 只增不减 + 仅上游成功路径同步的取舍见 .agents/notes/implemented/feature/2026-09-24-cline-auto-sync-free-catalog.md
17
+ *
18
+ * 纯逻辑(planMerge)无 IO,可单测;IO 全走注入。
19
+ */
20
+
21
+ /** 与 state/provider-config.js 的 normalizeAllowedModel 对齐:只剥首段等于 providerId 的前缀。调用方可注入同名函数保证单一真相。 */
22
+ export function defaultNormalize(model, providerId) {
23
+ let s = String(model || "").trim();
24
+ if (!s) return "";
25
+ const idx = s.indexOf("/");
26
+ if (idx > 0 && s.slice(0, idx).toLowerCase() === String(providerId || "").toLowerCase()) s = s.slice(idx + 1);
27
+ return s;
28
+ }
29
+
30
+ /**
31
+ * 求并集:保留 current 原顺序与全部既有条目,把 upstreamIds 中缺失的追加到尾部。
32
+ * @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[]}}
37
+ */
38
+ export function planMerge(current, upstreamIds, normalize = defaultNormalize, providerId = "") {
39
+ 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);
42
+ 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);
49
+ }
50
+ return { next, added };
51
+ }
52
+
53
+ /**
54
+ * @param {object} opts
55
+ * @param {string} opts.providerId
56
+ * @param {() => string[]} opts.loadCurrent 读当前白名单(裸 id 数组)
57
+ * @param {(id:string, list:string[]) => unknown} opts.persist 写回白名单
58
+ * @param {(msg:string) => void} [opts.onLog]
59
+ * @param {(m:string, pid:string)=>string} [opts.normalize]
60
+ * @param {boolean} [opts.enabled] 显式覆盖 env 开关
61
+ */
62
+ export function createAllowlistSync({ providerId, loadCurrent, persist, onLog, normalize = defaultNormalize, enabled } = {}) {
63
+ const isEnabled = () => {
64
+ if (typeof enabled === "boolean") return enabled;
65
+ return String(process.env.MSLXDFF_CLINE_AUTOSYNC ?? "") !== "0";
66
+ };
67
+ const log = (msg) => { try { onLog?.(msg); } catch {} };
68
+
69
+ /**
70
+ * 把一批上游模型并进白名单。
71
+ * @param {{id?:string}[]|string[]} models 上游模型对象数组或 id 数组
72
+ * @returns {{skipped:string, added:string[], total:number}}
73
+ */
74
+ 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 };
77
+ try {
78
+ const ids = models.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
79
+ const cur = loadCurrent() || [];
80
+ const { next, added } = planMerge(cur, ids, normalize, providerId);
81
+ if (!added.length) return { skipped: "", added: [], total: next.length };
82
+ persist(providerId, next);
83
+ log(`[${providerId}] allowlist auto-sync: +${added.length} → ${next.length} total (${added.join(", ")})`);
84
+ return { skipped: "", added, total: next.length };
85
+ } catch (err) {
86
+ // 同步是增益动作,失败绝不打断模型列表读取与请求链路
87
+ log(`[${providerId}] allowlist auto-sync failed: ${String(err?.message || err)}`);
88
+ return { skipped: "error", added: [], total: 0 };
89
+ }
90
+ }
91
+
92
+ return { syncIds };
93
+ }
@@ -256,12 +256,26 @@ export function createChatService({
256
256
  for (const k of ["temperature", "top_p", "tools", "tool_choice", "stop", "presence_penalty", "frequency_penalty", "response_format", "user", "n", "seed"]) {
257
257
  if (body[k] !== undefined) upstreamBody[k] = body[k];
258
258
  }
259
+ // Gemini 3.x Flash 经 Vertex 的 maxOutputTokens 上限 65536:客户端(PI-Desktop 默认
260
+ // 128000)原样透传即 400 invalid argument(网关 SSE 内 detail:
261
+ // "Unable to submit request because it has a maxOutputTokens va...")。
262
+ // 2026-09-25 直连实测:max 128000 必 400,65536/32000/500 全 200;
263
+ // reasoning_effort=high 单独不触发,仅夹上限。只夹 gemini,其他通道不动。
264
+ if (/gemini/i.test(String(upstreamModel || "")) && tokLimit > 65536) {
265
+ upstreamBody.max_tokens = 65536;
266
+ upstreamBody.max_completion_tokens = 65536;
267
+ }
259
268
  for (let netAttempt = 0; netAttempt < 3; netAttempt++) {
260
269
  try {
261
270
  const resp = await clineFetchWithRetry(upstreamBody, sessionId, isStream);
262
271
  if (!resp) throw new Error("empty response");
263
272
  if (!resp.ok) return resp;
264
- if (isStream) return resp;
273
+ if (isStream) {
274
+ // 旁路观测:把本次选中的账号哈希与裸 model 挂到透传 Response 上,供 relay 记账用。
275
+ // 转发链路不读这两个字段;记账失败也不影响返回。见 .agents/notes/implemented/feature/2026-09-24-cline-usage-dualwindow.md
276
+ try { resp.clineAccountId = acctId(authPool.getCurrentAccount()); resp.clineModel = model; } catch {}
277
+ return resp;
278
+ }
265
279
  if (forceStream) {
266
280
  const ret = await nonStreamWithContentCheck(upstreamBody, sessionId, resp);
267
281
  if (ret.error) return ret.error;
@@ -1,5 +1,5 @@
1
1
  import { createKeyRing } from "../keyring.js";
2
- import { loadProviderKeys, loadProviderBaseUrl, loadProviderModelsPath, loadProviderChatPath, saveProviderConfig, loadProviderAuths } from "../../state.js";
2
+ import { loadProviderKeys, loadProviderBaseUrl, loadProviderModelsPath, loadProviderChatPath, saveProviderConfig, loadProviderAuths, loadProviderAllowedModels, saveProviderAllowedModels, normalizeAllowedModel } from "../../state.js";
3
3
  import { envInt, joinUrl, getUndici, createAgent, collectApiKeysGeneric, createChatRunner } from "../base.js";
4
4
  import { compatFetch } from "../../compat.js";
5
5
  import { joinModelId } from "../model-id.js";
@@ -9,6 +9,7 @@ import { createAuthPool } from "./auth.js";
9
9
  import { clineHeaders, isRefreshToken } from "./headers.js";
10
10
  import { createChatService } from "./chat.js";
11
11
  import { createModelsService } from "./models.js";
12
+ import { createAllowlistSync } from "./allowlist-sync.js";
12
13
 
13
14
  const { UndiciFetch } = getUndici();
14
15
 
@@ -101,7 +102,18 @@ export function createClineProvider({
101
102
  }
102
103
 
103
104
  // 快照文件供 daemon 启动自检 free 列表增删(默认 logDir 下,gitignored;测试可注入 tmp 路径)
104
- const modelsSvc = createModelsService({ id, baseUrl: resolvedBase, modelsPath: resolvedModelsPath, fetchImpl, dispatcher, ring, loadKeys: (pid) => loadProviderKeys(pid, file ? { file } : {}), snapshotPath: snapshotPath || join(logDir(), "cline-free.json") });
105
+ // 白名单 auto-sync(上游列表 = 真相):每次成功取 recommended-models 后,把 free + clinePass
106
+ // 并进 providerConfigs.cline.allowedModels,只增不减——解决上游新上架/换通道的免费模型被静态快照拦成 blocked。
107
+ // persist 复用 saveProviderAllowedModels(保留 keys/auths/baseUrl,并保留 allowAnyModels)。
108
+ // 关掉:MSLXDFF_CLINE_AUTOSYNC=0;无新增不写盘。
109
+ const allowlistSync = createAllowlistSync({
110
+ providerId: id,
111
+ loadCurrent: () => loadProviderAllowedModels(id, file ? { file } : {}),
112
+ persist: (pid, list) => saveProviderAllowedModels(pid, list, file ? { file } : {}),
113
+ normalize: normalizeAllowedModel,
114
+ onLog: (msg) => console.log(msg),
115
+ });
116
+ const modelsSvc = createModelsService({ id, baseUrl: resolvedBase, modelsPath: resolvedModelsPath, fetchImpl, dispatcher, ring, loadKeys: (pid) => loadProviderKeys(pid, file ? { file } : {}), snapshotPath: snapshotPath || join(logDir(), "cline-free.json"), onModelsRefreshed: (list) => allowlistSync.syncIds(list) });
105
117
 
106
118
  async function listModels() {
107
119
  const list = await modelsSvc.listModels();
@@ -11,7 +11,16 @@ function isClineBotHost(baseUrl) {
11
11
  try { const u = new URL(baseUrl); return u.hostname === "api.cline.bot" || u.hostname.endsWith(".cline.bot"); } catch { return String(baseUrl).includes("cline.bot"); }
12
12
  }
13
13
 
14
- export function createModelsService({ id, baseUrl, modelsPath, fetchImpl, dispatcher, ring, loadKeys, snapshotPath } = {}) {
14
+ export function createModelsService({ id, baseUrl, modelsPath, fetchImpl, dispatcher, ring, loadKeys, snapshotPath, onModelsRefreshed } = {}) {
15
+ // 白名单 auto-sync 通知:只在上游成功取数后调用(内置兜底不是上游真相,不触发)。
16
+ // 异常一律吞掉——同步是增益动作,绝不打断模型列表读取与请求链路。
17
+ function notifyRefresh(list) {
18
+ if (!onModelsRefreshed) return;
19
+ try {
20
+ const r = onModelsRefreshed(list);
21
+ if (r && typeof r.catch === "function") r.catch(() => {});
22
+ } catch {}
23
+ }
15
24
  if (!fetchImpl) fetchImpl = UndiciFetch || compatFetch;
16
25
  const resolvedBase = String(baseUrl).trim().replace(/\/+$/, "");
17
26
  const resolvedPath = modelsPath || "/ai/cline/recommended-models";
@@ -63,12 +72,12 @@ export function createModelsService({ id, baseUrl, modelsPath, fetchImpl, dispat
63
72
  }
64
73
  }
65
74
  if (!all.length) return fallbackList();
66
- cache = all; fetchedAt = now; return all;
75
+ cache = all; fetchedAt = now; notifyRefresh(all); return all;
67
76
  }
68
77
  const raw = Array.isArray(json.data) ? json.data : Array.isArray(json.models) ? json.models : Array.isArray(json) ? json : [];
69
78
  const out = raw.filter((m) => m && typeof m.id === "string").map((m) => ({ ...m, id: joinModelId(id, m.id) }));
70
79
  if (!out.length) return fallbackList();
71
- cache = out; fetchedAt = now; return out;
80
+ cache = out; fetchedAt = now; notifyRefresh(out); return out;
72
81
  } catch { return fallbackList(); } finally { clearTimeout(timer); }
73
82
  }
74
83
 
@@ -121,6 +130,9 @@ export function createModelsService({ id, baseUrl, modelsPath, fetchImpl, dispat
121
130
  // 这里 cat.models 只有 free,写进去会把 pass 挤掉 10 分钟(daemon 启动后 /v1/models 就缺 pass)。
122
131
  let change = null;
123
132
  if (snapshotPath) { try { change = detectFreeChanges(valid.map((m) => m.id)); } catch {} }
133
+ // 自检同样是一次「读取上游」,故也喂给白名单 auto-sync:开机后即便没人访问 /v1/models,
134
+ // 新上架免费模型也已可用。这里只有 free(fetchFreeCatalog 只抽 free),clinePass 由 listModels 成功路径补齐。
135
+ notifyRefresh(valid);
124
136
  return {
125
137
  ok: true,
126
138
  status: cat.status,
@@ -1,15 +1,29 @@
1
- // Cline 账号 × 模型额度周期统计。
2
- // 目标:记录“额度恢复后到下一次限流”之间正常响应累计的 output/completion tokens。
1
+ // Cline 账号 × 模型用量统计(双口径)。
2
+ // - free 模型:按「额度周期」统计——限额恢复后开始 → 再次到限额之间累计的 output tokens。
3
+ // - pass 模型:按「最近 24h 滚动窗口」统计 output tokens。
4
+ // Note: 双口径设计(isFreeModel + aggregateUsage)见 .agents/notes/implemented/feature/2026-09-24-cline-usage-dualwindow.md
3
5
  // 不记录 token、邮箱、prompt、响应正文;账号只使用调用方传入的稳定哈希。
4
6
  import { appendFile, mkdir, readFile } from "node:fs/promises";
5
7
  import { join } from "node:path";
6
- import { logDir } from "../../logs.js";
7
- import { appendEvent } from "../../logs.js";
8
+ import { logDir, appendEvent } from "../../logs.js";
8
9
 
9
10
  const FILE = "cline-usage.jsonl";
10
11
  const states = new Map();
11
12
  let loaded;
12
13
 
14
+ // free 模型判定:后缀 -free / 含 :free / 已知免费家族;否则视为 pass(付费)
15
+ const KNOWN_FREE_MODELS = ["muse-spark", "space-bunny-alpha", "solar-pro4", "laguna-s-2.1"];
16
+ export function isFreeModel(id) {
17
+ const s = String(id || "").toLowerCase();
18
+ if (s.startsWith("cline-pass/")) return false;
19
+ if (s.startsWith("cline-free/")) return true;
20
+ if (s.endsWith("-free") || s.includes(":free")) return true;
21
+ if (KNOWN_FREE_MODELS.some((k) => s.includes(k))) return true;
22
+ return false;
23
+ }
24
+ // pass 模型统计窗口:最近 24 小时
25
+ export const FREE_WINDOW = 24 * 3600 * 1000;
26
+
13
27
  function keyOf(accountId, model) {
14
28
  return `${String(accountId || "unknown")}|${String(model || "unknown")}`;
15
29
  }
@@ -105,6 +119,7 @@ function event(row) {
105
119
  try { appendEvent({ ...row, type: "cline-model-usage" }); } catch {}
106
120
  }
107
121
 
122
+ // free 模型才走「限额恢复 → 再次限额」的周期开关;pass 模型不开新周期(其统计由 aggregateUsage 按 24h 窗口切片)。
108
123
  function stateFor(accountId, model, now) {
109
124
  const k = keyOf(accountId, model);
110
125
  const s = states.get(k) || newState();
@@ -116,6 +131,7 @@ function stateFor(accountId, model, now) {
116
131
  states.set(k, s);
117
132
  return s;
118
133
  }
134
+
119
135
  // 在消费终点直接记账(比包装 Response 可靠:streamToNonStream 已解析出 usage/正文,网关透传已有累积 bytes)。
120
136
  export function usageFromData(data) {
121
137
  const exact = exactOutputTokens(data?.usage);
@@ -127,15 +143,18 @@ export function usageFromData(data) {
127
143
  }
128
144
 
129
145
 
130
- export async function recordOutput({ accountId, model, tokens, estimated = false } = {}) {
131
- const n = Number(tokens);
146
+ export async function recordOutput({ accountId, model, tokens, outputTokens, estimated = false } = {}) {
147
+ // computeOutputRow 回的是 outputTokens,track/非流式路径传的是 tokens:两边都认,
148
+ // 否则 relay 旁路组合调用静默记不上(2026-09-25 gemini 流式零记账实测)。
149
+ const n = Number(tokens ?? outputTokens);
132
150
  if (!model || !Number.isFinite(n) || n <= 0) return null;
133
151
  await ensureLoaded();
134
152
  const now = Date.now();
153
+ const free = isFreeModel(model);
135
154
  const s = stateFor(accountId, model, now);
136
155
  s.currentOutputTokens += n;
137
156
  const row = {
138
- type: "output", at: now, accountId: accountId || "unknown", model,
157
+ type: "output", at: now, accountId: accountId || "unknown", model, modelType: free ? "free" : "pass",
139
158
  outputTokens: n, estimated: !!estimated, cycleOutputTokens: s.currentOutputTokens,
140
159
  cycleStartAt: s.cycleStartAt, cycleCount: s.completedCycles + 1,
141
160
  };
@@ -144,6 +163,7 @@ export async function recordOutput({ accountId, model, tokens, estimated = false
144
163
  return row;
145
164
  }
146
165
 
166
+ // 仅 free 模型会因 daily_limit 被封周期;pass 若收到 limit 也建 limit 行留痕,但不影响其 24h 窗口统计。
147
167
  export async function recordLimit({ accountId, model, reason = "daily_limit", status = 429, cooldownMs = 0 } = {}) {
148
168
  if (!model) return null;
149
169
  await ensureLoaded();
@@ -151,7 +171,7 @@ export async function recordLimit({ accountId, model, reason = "daily_limit", st
151
171
  const s = stateFor(accountId, model, now);
152
172
  if (s.limitedUntil && s.limitedUntil > now) return null;
153
173
  const row = {
154
- type: "limit", at: now, accountId: accountId || "unknown", model,
174
+ type: "limit", at: now, accountId: accountId || "unknown", model, modelType: isFreeModel(model) ? "free" : "pass",
155
175
  cycleOutputTokens: s.currentOutputTokens, cycleStartAt: s.cycleStartAt,
156
176
  cycleEndAt: now, until: now + Math.max(0, Number(cooldownMs) || 0),
157
177
  reason, status, cycleCount: s.completedCycles + 1,
@@ -168,3 +188,87 @@ export async function recordLimit({ accountId, model, reason = "daily_limit", st
168
188
  event({ ...row, state: "limit" });
169
189
  return row;
170
190
  }
191
+
192
+ /**
193
+ * 纯聚合:把 cline-usage.jsonl 的行数组算成「每账号×模型」的双口径统计。无 IO,可直接单测。
194
+ * - free:currentCycleTokens = 最近一次限额恢复后到此刻的累计;completedCycles = 已封存周期数;
195
+ * lastCompletedCycleTokens = 上一完整周期产出;totalTokens = 全时段总和。
196
+ * - pass:last24hTokens = 窗口内 (at > now-FREE_WINDOW) 的 output 之和;totalTokens = 全时段总和。
197
+ * @param {Array<{type:string,at:number,accountId:string,model:string,outputTokens:number,cycleOutputTokens?:number,cycleCount?:number,until?:number}>} rows
198
+ * @param {number} [now]
199
+ * @returns {Map<string,{accountId:string,model:string,modelType:"free"|"pass",currentCycleTokens:number,completedCycles:number,lastCompletedCycleTokens:number,last24hTokens:number,totalTokens:number}>}
200
+ */
201
+ export function aggregateUsage(rows, now = Date.now()) {
202
+ const out = new Map();
203
+ const windowStart = now - FREE_WINDOW;
204
+ // 先按 key 分组,组内按时间升序处理,保证「周期/窗口」判定稳定。
205
+ const grouped = new Map();
206
+ for (const r of Array.isArray(rows) ? rows : []) {
207
+ if (!r || !r.model) continue;
208
+ const k = keyOf(r.accountId, r.model);
209
+ if (!grouped.has(k)) grouped.set(k, []);
210
+ grouped.get(k).push(r);
211
+ }
212
+ for (const [k, list] of grouped) {
213
+ list.sort((a, b) => (Number(a.at) || 0) - (Number(b.at) || 0));
214
+ const model = list[0].model;
215
+ const accountId = list[0].accountId || "unknown";
216
+ const free = isFreeModel(model);
217
+ let totalTokens = 0;
218
+ let last24hTokens = 0;
219
+ // free 周期游标
220
+ let cycleTokens = 0;
221
+ let completedCycles = 0;
222
+ let lastCompletedCycleTokens = 0;
223
+ for (const r of list) {
224
+ const at = Number(r.at) || 0;
225
+ const n = Number(r.outputTokens) || 0;
226
+ if (r.type === "output") {
227
+ totalTokens += n;
228
+ if (at > windowStart) last24hTokens += n;
229
+ if (free) cycleTokens += n;
230
+ } else if (r.type === "limit" && free) {
231
+ // 封存当前周期
232
+ completedCycles += 1;
233
+ lastCompletedCycleTokens = cycleTokens;
234
+ cycleTokens = 0;
235
+ }
236
+ }
237
+ out.set(k, {
238
+ accountId, model, modelType: free ? "free" : "pass",
239
+ currentCycleTokens: free ? cycleTokens : 0,
240
+ completedCycles: free ? completedCycles : 0,
241
+ lastCompletedCycleTokens: free ? lastCompletedCycleTokens : 0,
242
+ last24hTokens,
243
+ totalTokens,
244
+ });
245
+ }
246
+ return out;
247
+ }
248
+
249
+ // 读取 JSONL 全量并聚合(供 CLI / 报表消费)。IO 失败返回空 Map,不影响调用方。
250
+ export async function loadAndAggregate(now = Date.now()) {
251
+ let text = "";
252
+ try { text = await readFile(join(logDir(), FILE), "utf8"); } catch { return new Map(); }
253
+ const rows = [];
254
+ for (const line of text.split("\n")) {
255
+ if (!line) continue;
256
+ try { rows.push(JSON.parse(line)); } catch {}
257
+ }
258
+ return aggregateUsage(rows, now);
259
+ }
260
+ // Note: 流式记账走 computeOutputRow(纯函数)+ recordOutput;reqId 登记表方案已否决(多一跳、无消费方),不保留。
261
+
262
+ /**
263
+ * 从 relay-done 的流结果算 cline 专属 output tokens(纯函数,无 IO)。
264
+ * usage 有 completion_tokens → 记精确(estimated=false);否则按 detail.chars 估算(estimated=true)。
265
+ * @param {{model:string, accountId?:string, usage?:object, chars?:number|null}} opts
266
+ * @returns {{accountId:string, model:string, outputTokens:number, estimated:boolean}|null} 零/负 token 返回 null(跳过)
267
+ */
268
+ export function computeOutputRow({ model, accountId, usage, chars } = {}) {
269
+ if (!model) return null;
270
+ const exact = exactOutputTokens(usage);
271
+ const n = exact > 0 ? exact : (Number(chars) > 0 ? Math.ceil(Number(chars) / 4) : 0);
272
+ if (!Number.isFinite(n) || n <= 0) return null;
273
+ return { accountId: accountId || "unknown", model, outputTokens: n, estimated: exact <= 0 };
274
+ }
@@ -3,6 +3,7 @@ import { recordModelStats } from "../../state.js";
3
3
  import { normalizeFullId } from "../../providers/model-id.js";
4
4
  import { computeMetrics } from "../../metrics.js";
5
5
  import { recordChatUsage } from "../../usage/record.js"; // 窗口报表唯一写入点(canonical 名单记防双计)— 见 .agents/notes/implemented/feature/2026-09-19-usage-report-jsonl.md
6
+ import { recordOutput, computeOutputRow } from "../../providers/cline/usage.js"; // cline 专属旁路统计(账号×模型,流式也覆盖)
6
7
 
7
8
  // 唯一/最后候选没有 failover 去向:首块闸门退化为纯"防连接泄漏",放宽避免误杀慢模型
8
9
  // (参考 opencode:zen 通道不设超时;openai responses 硬编码 300s headerTimeout)
@@ -11,6 +12,11 @@ export const LAST_CANDIDATE_TIMEOUT_MS = (() => {
11
12
  return Number.isInteger(n) && n >= 0 ? n : 120_000;
12
13
  })();
13
14
 
15
+ /** 空转 200 判定(serial-trial 同模型重试用):与 execute 内 _emptyTurn 产生的 lastErr 同源 */
16
+ export function isEmptyTurnError(err) {
17
+ return Number(err?.status) === 502 && String(err?.message || "").startsWith("EMPTY_MODEL_RESPONSE");
18
+ }
19
+
14
20
  /**
15
21
  * RelayPipeline 深模块
16
22
  * 把 5 个 handler 各自的 fallback→relay→scoring→事件 6段流水收敛为单一真相。
@@ -216,6 +222,13 @@ export function createRelayPipeline({
216
222
  // 窗口报表:逐请求落 usage(行形状由 usage/record.js 拥有,含 prompt/total ——
217
223
  // state 的 modelStats 只存 completion 的 EMA)。只按 canonical 名记一次,避免双计。
218
224
  recordChatUsage({ model: fullId, via, usage, ttfbMs: ttfb, totalMs: total, tps: m.tps }).catch(() => {});
225
+ // cline 旁路记账:流式时 provider 已把账号哈希挂在 upRes 上,这里用消费完的 usage 记一笔。
226
+ // 纯旁路:只调 recordOutput,不改转发/切号/重试;无账号或非 cline 直接跳过。
227
+ if (upRes?.clineAccountId) {
228
+ const clineModel = upRes.clineModel || String(actual).replace(/^cline\//, "");
229
+ const orow = computeOutputRow({ model: clineModel, accountId: upRes.clineAccountId, usage, chars });
230
+ if (orow) recordOutput(orow).catch(() => {});
231
+ }
219
232
  } catch {}
220
233
  }
221
234
 
@@ -27,10 +27,15 @@ export function saveProviderAllowedModels(id, list, { file = defaultStateFile()
27
27
  const auths = normalizeAuths(cur.auths);
28
28
  const modelsPath = typeof cur.modelsPath === "string" ? normalizeEndpointPath(cur.modelsPath) : "";
29
29
  const chatPath = typeof cur.chatPath === "string" ? normalizeEndpointPath(cur.chatPath) : "";
30
- if (!baseUrl && !keys.length && !auths.length && !clean.length && !modelsPath && !chatPath) {
30
+ // allowAnyModels 是独立语义开关:只有它也设过时,即使其余字段全空也不能整键删(否则 clear 会把「允许任意」连带抹掉,供应商从放行降级成全拦 403)
31
+ if (!baseUrl && !keys.length && !auths.length && !clean.length && !modelsPath && !chatPath && typeof cur.allowAnyModels !== "boolean") {
31
32
  delete configs[id];
32
33
  } else {
33
34
  configs[id] = { baseUrl, keys };
35
+ // 重建 cfg 是 `{ baseUrl, keys }` 起手,不显式带 allowAnyModels 就会静默丢用户的「允许任意」设置
36
+ // (实测 2026-09-24:saveProviderAllowedModels 把 allowAnyModels:true 抹成默认 false)。
37
+ // cline 白名单 auto-sync 会周期性写这里,丢了就是持续丢,必须保留。
38
+ if (typeof cur.allowAnyModels === "boolean") configs[id].allowAnyModels = cur.allowAnyModels;
34
39
  if (auths.length) configs[id].auths = auths;
35
40
  if (clean.length) configs[id].allowedModels = clean;
36
41
  if (modelsPath) configs[id].modelsPath = modelsPath;
@@ -183,6 +183,8 @@ export function saveProviderConfig(id, { baseUrl, keys, auths, allowedModels, mo
183
183
  if (!cleanUrl && !cleanKeys.length && !finalAllowed.length && !finalAuths.length && !finalModelsPath && !finalChatPath) delete configs[id];
184
184
  else {
185
185
  configs[id] = { baseUrl: cleanUrl, keys: cleanKeys };
186
+ // 同 saveProviderAllowedModels:起手不带 allowAnyModels 就会静默丢用户的「允许任意」设置(cline token 轮换每走一次这里就丢一次)
187
+ if (typeof cur.allowAnyModels === "boolean") configs[id].allowAnyModels = cur.allowAnyModels;
186
188
  if (finalAuths.length) configs[id].auths = finalAuths;
187
189
  if (finalAllowed.length) configs[id].allowedModels = finalAllowed;
188
190
  if (finalModelsPath) configs[id].modelsPath = finalModelsPath;