mslxdff 0.1.149 → 0.1.151
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 +1 -1
- package/src/cli/commands/provider/cline-free.js +7 -2
- package/src/cli/commands/provider/cline-quota.js +65 -0
- package/src/cli/commands/provider/index.js +2 -0
- package/src/cli/format.js +15 -0
- package/src/providers/cline/allowlist-sync.js +93 -0
- package/src/providers/cline/auth.js +55 -11
- package/src/providers/cline/chat.js +88 -7
- package/src/providers/cline/index.js +14 -2
- package/src/providers/cline/models.js +15 -3
- package/src/providers/cline/usage.js +274 -0
- package/src/providers/dispatcher.js +25 -6
- package/src/routes/chat/relay-pipeline.js +8 -0
- package/src/runtime/providers-setup.js +2 -0
- package/src/state/schemas/allowlist.js +6 -1
- package/src/state/schemas/provider.js +2 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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");
|
package/src/cli/format.js
CHANGED
|
@@ -109,6 +109,21 @@ export function fmtEvent(e) {
|
|
|
109
109
|
case "auto-update-query-failed":
|
|
110
110
|
case "auto-update-install-failed":
|
|
111
111
|
return `${head} auto-update failed ${e.type} error=${e.error||""}`;
|
|
112
|
+
case "cline-account-state": {
|
|
113
|
+
// 限流是「账号 × 模型」维度:模型名打头,pool 表示该模型上的可用账号,
|
|
114
|
+
// 避免把"这个模型的免费额度用尽"误读成"整池账号报废"。
|
|
115
|
+
const p = e.pool ? ` acct-ready=${e.pool.ready}/${e.pool.total}` : "";
|
|
116
|
+
const lm = e.limitedModel ? ` upstream=${e.limitedModel}` : "";
|
|
117
|
+
const detail = [e.reason, e.status ? `HTTP ${e.status}` : "", e.cooldownMs ? `cool ${Math.round(e.cooldownMs / 1000)}s` : ""].filter(Boolean).join(" ");
|
|
118
|
+
const hint = e.state === "pool-exhausted" ? " [该模型所有账号额度耗尽 → 换模型或等冷却]" : e.state === "switch" ? " [换号重试]" : e.state === "force-retry" ? " [全部号冷却中,强行取号重试]" : "";
|
|
119
|
+
return `${head} cline acct ${e.state} model=${m(e.model)}${lm} account=${m(e.accountId)}${e.from ? ` from=${e.from}` : ""}${detail ? ` ${detail}` : ""}${p}${hint}`;
|
|
120
|
+
}
|
|
121
|
+
case "cline-model-usage":
|
|
122
|
+
return `${head} cline tokens ${e.state === "limit" ? "限流封存" : "正常输出"}${e.estimated ? "(估算)" : ""} model=${m(e.model)} account=${m(e.accountId)} output=${m(e.outputTokens)} 周期累计=${m(e.cycleOutputTokens)}${e.cycleCount ? ` 第${e.cycleCount}周期` : ""}${e.reason ? ` reason=${e.reason}` : ""}`;
|
|
123
|
+
case "provider-state":
|
|
124
|
+
return `${head} provider ${m(e.provider)} ${m(e.state)}${e.keys !== undefined ? ` keys=${e.keys}` : ""}${e.accounts !== undefined ? ` accounts=${e.accounts}` : ""}${e.baseUrl ? ` baseUrl=${e.baseUrl}` : ""}${e.error ? ` error=${e.error}` : ""}`;
|
|
125
|
+
case "provider-model-state":
|
|
126
|
+
return `${head} prov model ${m(e.provider)} ${m(e.state)} model=${m(e.model)}${e.status ? ` HTTP ${e.status}` : ""}${e.error ? ` error=${e.error}` : ""}${e.durationMs !== undefined ? ` ${fmtDur(e.durationMs)}` : ""}`;
|
|
112
127
|
default:
|
|
113
128
|
return `${head} ${e?.type || "?"} ${JSON.stringify(e || {})}`;
|
|
114
129
|
}
|
|
@@ -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
|
+
}
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import { joinUrl, sleep } from "../base.js";
|
|
2
2
|
import { compatFetch } from "../../compat.js";
|
|
3
|
+
import { appendEvent } from "../../logs.js";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Cline Token 池:多账号 round-robin + refresh 换 accessToken + 冷却 + 队列
|
|
6
8
|
* 对标 pingmike2/cline2api-workers 的 accounts / getAccountToken / parseCooldown / enqueue
|
|
7
9
|
*/
|
|
8
10
|
|
|
11
|
+
|
|
12
|
+
// 只记哈希标识,不落邮箱 / refreshToken(events.log 可读不可泄)
|
|
13
|
+
function acctId(account) {
|
|
14
|
+
const seed = String(account?.refreshToken || "");
|
|
15
|
+
if (!seed) return "slot-unknown";
|
|
16
|
+
return `acct_${createHash("sha256").update(seed).digest("hex").slice(0, 8)}`;
|
|
17
|
+
}
|
|
9
18
|
export function parseCooldown(body, status) {
|
|
10
19
|
const m = String(body || "").match(/try again in (?:(\d+)\s*h)?\s*(?:(\d+)\s*m)?\s*(?:(\d+)\s*s)?/i);
|
|
11
20
|
if (m) {
|
|
@@ -86,7 +95,7 @@ export function createAuthPool({
|
|
|
86
95
|
}
|
|
87
96
|
const changed = accounts.length !== list.length || accounts.some((a, i) => a.refreshToken !== list[i]);
|
|
88
97
|
if (changed) {
|
|
89
|
-
accounts = list.map((rt) => ({ refreshToken: rt, accessToken: null, expiry: 0, cooldownUntil: 0 }));
|
|
98
|
+
accounts = list.map((rt) => ({ refreshToken: rt, accessToken: null, expiry: 0, cooldownUntil: 0, limits: {} }));
|
|
90
99
|
accountIndex = 0;
|
|
91
100
|
}
|
|
92
101
|
return accounts;
|
|
@@ -100,14 +109,27 @@ export function createAuthPool({
|
|
|
100
109
|
queueTail = run.catch(() => {});
|
|
101
110
|
return run;
|
|
102
111
|
}
|
|
112
|
+
// 模型级限流:Cline 免费额度按「账号 × 模型」计,429 只应挡该模型,
|
|
113
|
+
// 不得把整个号冻住(muse-spark 被限时 deepseek 仍须能用同一账号)。
|
|
114
|
+
function markLimit(account, model, cooldownMs) {
|
|
115
|
+
if (!account || !model) return;
|
|
116
|
+
if (!account.limits) account.limits = {};
|
|
117
|
+
account.limits[model] = clock() + cooldownMs;
|
|
118
|
+
}
|
|
119
|
+
// 账号可用性:dead(账号死)/ cooldownUntil(refresh 失败,账号级)/ limits[model](该模型 429)三关
|
|
120
|
+
function accountAvailable(acc, model, now = clock()) {
|
|
121
|
+
if (!acc || acc.dead) return false;
|
|
122
|
+
if (acc.cooldownUntil && acc.cooldownUntil > now) return false;
|
|
123
|
+
if (model && acc.limits && acc.limits[model] && acc.limits[model] > now) return false;
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
103
126
|
|
|
104
|
-
function pickAccount(pool) {
|
|
127
|
+
function pickAccount(pool, model) {
|
|
105
128
|
const list = pool || accounts;
|
|
106
129
|
for (let k = 0; k < list.length; k++) {
|
|
107
130
|
const acc = list[accountIndex % list.length];
|
|
108
131
|
accountIndex = (accountIndex + 1) % list.length;
|
|
109
|
-
if (acc
|
|
110
|
-
if (!acc.cooldownUntil || acc.cooldownUntil <= clock()) {
|
|
132
|
+
if (accountAvailable(acc, model)) {
|
|
111
133
|
currentAccount = acc;
|
|
112
134
|
return acc;
|
|
113
135
|
}
|
|
@@ -135,8 +157,14 @@ export function createAuthPool({
|
|
|
135
157
|
if (!res.ok) {
|
|
136
158
|
let t = "";
|
|
137
159
|
try { t = await res.text(); } catch {}
|
|
138
|
-
if (isInvalidGrant(t, res.status)) {
|
|
139
|
-
|
|
160
|
+
if (isInvalidGrant(t, res.status)) {
|
|
161
|
+
account.dead = true;
|
|
162
|
+
appendEvent({ type: "cline-account-state", provider: id, state: "dead", accountId: acctId(account), status: res.status, reason: "invalid_grant" });
|
|
163
|
+
throw new Error("invalid_grant");
|
|
164
|
+
}
|
|
165
|
+
account.cooldownUntil = now + 60 * 1000;
|
|
166
|
+
appendEvent({ type: "cline-account-state", provider: id, state: "refresh-failed", accountId: acctId(account), status: res.status, cooldownMs: 60_000 });
|
|
167
|
+
throw new Error("refresh_failed");
|
|
140
168
|
}
|
|
141
169
|
let data;
|
|
142
170
|
try { data = await res.json(); } catch { account.cooldownUntil = now + 60 * 1000; throw new Error("refresh_no_token"); }
|
|
@@ -159,20 +187,36 @@ export function createAuthPool({
|
|
|
159
187
|
return accessToken;
|
|
160
188
|
}
|
|
161
189
|
|
|
162
|
-
async function getAccessToken() {
|
|
190
|
+
async function getAccessToken(model) {
|
|
163
191
|
const pool = accounts;
|
|
164
192
|
if (pool.length === 0) throw new Error("缺少 CLINE_REFRESH_TOKEN(请用 cline_oauth.py 获取)");
|
|
165
193
|
for (let attempt = 0; attempt < pool.length; attempt++) {
|
|
166
194
|
const acc = pool[attempt % pool.length];
|
|
167
|
-
if (acc
|
|
168
|
-
if (acc.cooldownUntil && acc.cooldownUntil > clock()) continue;
|
|
195
|
+
if (!accountAvailable(acc, model)) continue;
|
|
169
196
|
currentAccount = acc;
|
|
170
197
|
try { return await refreshOne(acc); } catch (e) { if (e.message === "account_cooldown") continue; continue; }
|
|
171
198
|
}
|
|
172
199
|
const acc = pool.find((a) => !a.dead) || null;
|
|
173
200
|
if (!acc) throw new Error("无可用 Cline 账号");
|
|
174
201
|
currentAccount = acc;
|
|
175
|
-
|
|
202
|
+
// 防锁死:该模型全部号被限时,清掉这一号的该模型限制强取重试(只清模型维度,
|
|
203
|
+
// 不动账号级 cooldownUntil——那是 refresh 失败的冷却,仍由 accountAvailable 把关)。
|
|
204
|
+
if (model && acc.limits) delete acc.limits[model];
|
|
205
|
+
if (acc.cooldownUntil && acc.cooldownUntil > clock()) acc.cooldownUntil = 0;
|
|
206
|
+
appendEvent({
|
|
207
|
+
type: "cline-account-state",
|
|
208
|
+
provider: id,
|
|
209
|
+
state: "force-retry",
|
|
210
|
+
accountId: acctId(acc),
|
|
211
|
+
model,
|
|
212
|
+
reason: "all-accounts-cooling",
|
|
213
|
+
pool: {
|
|
214
|
+
total: pool.length,
|
|
215
|
+
cooling: pool.filter((a) => !accountAvailable(a, model)).length,
|
|
216
|
+
dead: pool.filter((a) => a.dead).length,
|
|
217
|
+
ready: pool.filter((a) => accountAvailable(a, model)).length,
|
|
218
|
+
},
|
|
219
|
+
});
|
|
176
220
|
return refreshOne(acc);
|
|
177
221
|
}
|
|
178
222
|
|
|
@@ -180,5 +224,5 @@ export function createAuthPool({
|
|
|
180
224
|
function getAccounts() { return accounts; }
|
|
181
225
|
function updateKeys(newKeys) { parseAccounts(newKeys); }
|
|
182
226
|
|
|
183
|
-
return { parseAccounts, getAccessToken, refreshOne, pickAccount, parseCooldown, enqueue, getCurrentAccount, getAccounts, updateKeys, _accounts: () => accounts };
|
|
227
|
+
return { parseAccounts, getAccessToken, refreshOne, pickAccount, parseCooldown, enqueue, getCurrentAccount, getAccounts, updateKeys, markLimit, accountAvailable, _accounts: () => accounts };
|
|
184
228
|
}
|
|
@@ -1,11 +1,41 @@
|
|
|
1
1
|
import { joinUrl, sleep } from "../base.js";
|
|
2
|
+
import { appendEvent } from "../../logs.js";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
2
4
|
import { clineHeaders } from "./headers.js";
|
|
3
5
|
import { createTransport } from "../../transport/index.js";
|
|
4
6
|
import { normalizeProviderId } from "../model-id.js";
|
|
7
|
+
import { recordLimit, recordOutput, exactOutputTokens } from "./usage.js";
|
|
5
8
|
import { createSdkDispatch } from "../../upstream-engine/sdk/dispatch.js";
|
|
6
9
|
|
|
7
10
|
function genSessionId() { return `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; }
|
|
8
11
|
|
|
12
|
+
// 只记哈希标识,不落邮箱 / refreshToken(events.log 可读不可泄)
|
|
13
|
+
function acctId(account) {
|
|
14
|
+
const seed = String(account?.refreshToken || "");
|
|
15
|
+
if (!seed) return "slot-unknown";
|
|
16
|
+
return `acct_${createHash("sha256").update(seed).digest("hex").slice(0, 8)}`;
|
|
17
|
+
}
|
|
18
|
+
// 池子快照:ready/cooling 按「该模型」口径统计(ready=对该模型可用的号数)。
|
|
19
|
+
// isReady 由调用方注入 authPool.accountAvailable(a, model),避免与 auth 逻辑分叉。
|
|
20
|
+
function poolStat(pool, isReady = () => true) {
|
|
21
|
+
let cooling = 0;
|
|
22
|
+
let dead = 0;
|
|
23
|
+
for (const a of pool || []) {
|
|
24
|
+
if (a.dead) dead++;
|
|
25
|
+
else if (!isReady(a)) cooling++;
|
|
26
|
+
}
|
|
27
|
+
const total = (pool || []).length;
|
|
28
|
+
return { total, cooling, dead, ready: total - cooling - dead };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 429 错误体自带权威模型名("...Daily free limit reached on model meta/muse-spark-1.3-contributor")。
|
|
32
|
+
function limitModelOf(bodyText) {
|
|
33
|
+
// 模型名可含 / . - _ : 字母数字(如 meta/muse-spark-1.3-contributor),
|
|
34
|
+
// 遇空格/引号/逗号/句号结尾或 "Try again" 前的空白即停。
|
|
35
|
+
const m = String(bodyText || "").match(/on model\s+([A-Za-z0-9/._:-]+?)(?=[\s"',]|\.\s|$)/i);
|
|
36
|
+
return m ? m[1] : "";
|
|
37
|
+
}
|
|
38
|
+
|
|
9
39
|
// stripProviderPrefix 只剥「本供应商」前缀(cline/<裸 id> → 裸 id),非本前缀(含上游自带多段 id
|
|
10
40
|
// 如 meta/vendor/x)原样透传,不误削首段(旧实现按段数剥曾是潜在 bug)。
|
|
11
41
|
function stripProviderPrefix(model, providerId) {
|
|
@@ -23,7 +53,7 @@ function unwrapData(obj) {
|
|
|
23
53
|
return obj;
|
|
24
54
|
}
|
|
25
55
|
|
|
26
|
-
async function streamToNonStream(upstream) {
|
|
56
|
+
async function streamToNonStream(upstream, track = null) {
|
|
27
57
|
let content = "";
|
|
28
58
|
let reasoning = "";
|
|
29
59
|
let finishReason = null;
|
|
@@ -49,6 +79,11 @@ async function streamToNonStream(upstream) {
|
|
|
49
79
|
const msg = { role: "assistant", content };
|
|
50
80
|
if (reasoning) msg.reasoning = reasoning;
|
|
51
81
|
if (!content && reasoning) { msg.content = reasoning; msg.reasoning_used_as_content = true; }
|
|
82
|
+
if (track) {
|
|
83
|
+
const exact = exactOutputTokens(usage);
|
|
84
|
+
if (exact) track({ tokens: exact, estimated: false });
|
|
85
|
+
else if (content || reasoning) track({ tokens: Math.ceil((content.length + reasoning.length) / 4), estimated: true });
|
|
86
|
+
}
|
|
52
87
|
return {
|
|
53
88
|
id: id || `gen_${Date.now()}`,
|
|
54
89
|
object: "chat.completion",
|
|
@@ -74,7 +109,8 @@ export function createChatService({
|
|
|
74
109
|
const sdk = createSdkDispatch({ id, providerName: id || "cline" });
|
|
75
110
|
|
|
76
111
|
async function clineFetch(body, sessionId, allowSdk = false) {
|
|
77
|
-
const token = await authPool.getAccessToken();
|
|
112
|
+
const token = await authPool.getAccessToken(body?.model);
|
|
113
|
+
appendEvent({ type: "cline-account-state", provider: id || "cline", state: "selected", accountId: acctId(authPool.getCurrentAccount()), model: body?.model, pool: poolStat(authPool.getAccounts(), (a) => authPool.accountAvailable(a, body?.model)) });
|
|
78
114
|
const headers = clineHeaders(sessionId, token);
|
|
79
115
|
const finalUrl = joinUrl(resolvedBase, resolvedChat);
|
|
80
116
|
const isStream = body?.stream === true;
|
|
@@ -85,6 +121,8 @@ export function createChatService({
|
|
|
85
121
|
}
|
|
86
122
|
return transport.request({ url: finalUrl, headers, body, stream: isStream, timeoutMs: connectTimeoutMs });
|
|
87
123
|
}
|
|
124
|
+
// 记账回调:捕获当前账号哈希,成功输出进当前额度周期
|
|
125
|
+
const track = (model) => ({ tokens, estimated }) => recordOutput({ accountId: acctId(authPool.getCurrentAccount()), model, tokens, estimated }).catch(() => {});
|
|
88
126
|
|
|
89
127
|
function isLimitHit(status, bodyText) {
|
|
90
128
|
if (status === 429) return true;
|
|
@@ -113,9 +151,32 @@ export function createChatService({
|
|
|
113
151
|
const { parseCooldown } = await import("./auth.js");
|
|
114
152
|
const cooldownMs = parseCooldown(bodyText, resp.status);
|
|
115
153
|
const cur = authPool.getCurrentAccount();
|
|
116
|
-
|
|
154
|
+
// 免费额度按「账号 × 模型」计:只把该模型挂到这个号上(limits[model]),
|
|
155
|
+
// 不动账号级 cooldownUntil、不清 accessToken——同号其它模型照常可用。
|
|
156
|
+
authPool.markLimit(cur, body?.model, cooldownMs);
|
|
157
|
+
const limitedModel = limitModelOf(bodyText);
|
|
158
|
+
if (limitedModel && limitedModel !== body?.model) authPool.markLimit(cur, limitedModel, cooldownMs);
|
|
159
|
+
const cycle = await recordLimit({ accountId: acctId(cur), model: body?.model, reason: resp.status === 429 ? "daily_limit" : "empty_response", status: resp.status, cooldownMs });
|
|
117
160
|
const pool = authPool.getAccounts();
|
|
118
|
-
const
|
|
161
|
+
const ready = (a) => authPool.accountAvailable(a, body?.model);
|
|
162
|
+
const hasOther = pool.some(ready);
|
|
163
|
+
// 限流是「账号 × 模型」维度:429 只说明当前账号在这个模型上额度用尽,
|
|
164
|
+
// 换模型(如 muse-spark → deepseek)往往仍可用,故日志必须点名模型。
|
|
165
|
+
appendEvent({
|
|
166
|
+
type: "cline-account-state",
|
|
167
|
+
provider: id || "cline",
|
|
168
|
+
state: hasOther ? "switch" : "pool-exhausted",
|
|
169
|
+
accountId: acctId(cur),
|
|
170
|
+
model: body?.model,
|
|
171
|
+
limitedModel: limitedModel || undefined,
|
|
172
|
+
scope: "model",
|
|
173
|
+
reason: resp.status === 429 ? "daily_limit" : "empty_response",
|
|
174
|
+
status: resp.status,
|
|
175
|
+
cooldownMs,
|
|
176
|
+
pool: poolStat(pool, ready),
|
|
177
|
+
cycleOutputTokens: cycle?.cycleOutputTokens ?? 0,
|
|
178
|
+
cycleCount: cycle?.cycleCount ?? 0,
|
|
179
|
+
});
|
|
119
180
|
if (!hasOther) return resp;
|
|
120
181
|
await sleep(500 + Math.floor(Math.random() * 500));
|
|
121
182
|
continue;
|
|
@@ -140,7 +201,7 @@ export function createChatService({
|
|
|
140
201
|
}
|
|
141
202
|
const ct = resp.headers.get("content-type") || "";
|
|
142
203
|
let normalized = null;
|
|
143
|
-
if (ct.includes("text/event-stream")) normalized = await streamToNonStream(resp);
|
|
204
|
+
if (ct.includes("text/event-stream")) normalized = await streamToNonStream(resp, track(body?.model));
|
|
144
205
|
else {
|
|
145
206
|
const raw = await resp.json().catch(() => null);
|
|
146
207
|
if (raw) normalized = unwrapData(raw);
|
|
@@ -154,7 +215,9 @@ export function createChatService({
|
|
|
154
215
|
if (content && !isFallback) return { data: normalized };
|
|
155
216
|
if (reasoning || isFallback) {
|
|
156
217
|
const cur = authPool.getCurrentAccount();
|
|
157
|
-
|
|
218
|
+
// 空响应是「模型 × 通道」形态问题(deepseek 非流式特性),同样只挡该模型。
|
|
219
|
+
authPool.markLimit(cur, body?.model, 30 * 1000);
|
|
220
|
+
appendEvent({ type: "cline-account-state", provider: id || "cline", state: "cooldown", accountId: acctId(cur), model: body?.model, reason: "empty_response", cooldownMs: 30000, scope: "model", pool: poolStat(authPool.getAccounts(), (a) => authPool.accountAvailable(a, body?.model)) });
|
|
158
221
|
await sleep(300 + Math.floor(Math.random() * 300));
|
|
159
222
|
resp = null;
|
|
160
223
|
continue;
|
|
@@ -193,12 +256,26 @@ export function createChatService({
|
|
|
193
256
|
for (const k of ["temperature", "top_p", "tools", "tool_choice", "stop", "presence_penalty", "frequency_penalty", "response_format", "user", "n", "seed"]) {
|
|
194
257
|
if (body[k] !== undefined) upstreamBody[k] = body[k];
|
|
195
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
|
+
}
|
|
196
268
|
for (let netAttempt = 0; netAttempt < 3; netAttempt++) {
|
|
197
269
|
try {
|
|
198
270
|
const resp = await clineFetchWithRetry(upstreamBody, sessionId, isStream);
|
|
199
271
|
if (!resp) throw new Error("empty response");
|
|
200
272
|
if (!resp.ok) return resp;
|
|
201
|
-
if (isStream)
|
|
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
|
+
}
|
|
202
279
|
if (forceStream) {
|
|
203
280
|
const ret = await nonStreamWithContentCheck(upstreamBody, sessionId, resp);
|
|
204
281
|
if (ret.error) return ret.error;
|
|
@@ -213,6 +290,10 @@ export function createChatService({
|
|
|
213
290
|
}
|
|
214
291
|
const normalized = unwrapData(raw);
|
|
215
292
|
normalized.model = model;
|
|
293
|
+
// 非流式终点:正文估算记账(上游有 usage 时 streamToNonStream 路径才拿得到精确值;此处 raw 无流)
|
|
294
|
+
const m2 = normalized?.choices?.[0]?.message || {};
|
|
295
|
+
const outLen2 = String(m2.content || "").length + String(m2.reasoning || "").length;
|
|
296
|
+
if (outLen2 > 0) track(model)({ tokens: Math.ceil(outLen2 / 4), estimated: true });
|
|
216
297
|
return new Response(JSON.stringify(normalized), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
217
298
|
} catch (err) {
|
|
218
299
|
if (netAttempt < 2 && String(err?.message || "").toLowerCase().includes("timed out")) { await sleep(300); continue; }
|
|
@@ -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
|
-
|
|
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,
|
|
@@ -0,0 +1,274 @@
|
|
|
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
|
|
5
|
+
// 不记录 token、邮箱、prompt、响应正文;账号只使用调用方传入的稳定哈希。
|
|
6
|
+
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { logDir, appendEvent } from "../../logs.js";
|
|
9
|
+
|
|
10
|
+
const FILE = "cline-usage.jsonl";
|
|
11
|
+
const states = new Map();
|
|
12
|
+
let loaded;
|
|
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
|
+
|
|
27
|
+
function keyOf(accountId, model) {
|
|
28
|
+
return `${String(accountId || "unknown")}|${String(model || "unknown")}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function newState() {
|
|
32
|
+
return {
|
|
33
|
+
currentOutputTokens: 0,
|
|
34
|
+
cycleStartAt: Date.now(),
|
|
35
|
+
limitedUntil: 0,
|
|
36
|
+
completedCycles: 0,
|
|
37
|
+
lastCycleOutputTokens: 0,
|
|
38
|
+
lastCycleStartAt: null,
|
|
39
|
+
lastCycleEndAt: null,
|
|
40
|
+
lastReason: null,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function ensureLoaded() {
|
|
45
|
+
if (!loaded) {
|
|
46
|
+
loaded = (async () => {
|
|
47
|
+
let text = "";
|
|
48
|
+
try { text = await readFile(join(logDir(), FILE), "utf8"); } catch { return; }
|
|
49
|
+
for (const line of text.split("\n")) {
|
|
50
|
+
if (!line) continue;
|
|
51
|
+
try {
|
|
52
|
+
const row = JSON.parse(line);
|
|
53
|
+
const k = keyOf(row.accountId, row.model);
|
|
54
|
+
const s = states.get(k) || newState();
|
|
55
|
+
if (row.type === "output") {
|
|
56
|
+
s.currentOutputTokens = Number(row.cycleOutputTokens) || 0;
|
|
57
|
+
s.cycleStartAt = Number(row.cycleStartAt) || Number(row.at) || s.cycleStartAt;
|
|
58
|
+
s.limitedUntil = 0;
|
|
59
|
+
} else if (row.type === "limit") {
|
|
60
|
+
s.completedCycles = Math.max(0, (Number(row.cycleCount) || 1) - 1);
|
|
61
|
+
s.lastCycleOutputTokens = Number(row.cycleOutputTokens) || 0;
|
|
62
|
+
s.lastCycleStartAt = Number(row.cycleStartAt) || null;
|
|
63
|
+
s.lastCycleEndAt = Number(row.at) || null;
|
|
64
|
+
s.lastReason = row.reason || null;
|
|
65
|
+
s.currentOutputTokens = 0;
|
|
66
|
+
s.cycleStartAt = 0;
|
|
67
|
+
s.limitedUntil = Number(row.until) || 0;
|
|
68
|
+
}
|
|
69
|
+
states.set(k, s);
|
|
70
|
+
} catch {}
|
|
71
|
+
}
|
|
72
|
+
})();
|
|
73
|
+
}
|
|
74
|
+
return loaded;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function exactOutputTokens(usage) {
|
|
78
|
+
const n = Number(usage?.completion_tokens ?? usage?.completionTokens ?? usage?.output_tokens ?? usage?.outputTokens);
|
|
79
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 上游有时不给 usage(本次真实 Cline 响应就是如此);按输出正文估算并明确标记 estimated。
|
|
83
|
+
function parseOutput(text) {
|
|
84
|
+
const s = String(text || "");
|
|
85
|
+
let parsed = null;
|
|
86
|
+
try { parsed = JSON.parse(s); } catch {}
|
|
87
|
+
if (parsed) {
|
|
88
|
+
const exact = exactOutputTokens(parsed?.usage || parsed);
|
|
89
|
+
if (exact) return { tokens: exact, estimated: false };
|
|
90
|
+
const output = parsed?.choices?.[0]?.message || parsed?.choices?.[0]?.delta || parsed?.output || parsed;
|
|
91
|
+
return { tokens: Math.ceil(JSON.stringify(output || "").length / 4), estimated: true };
|
|
92
|
+
}
|
|
93
|
+
let usage = null;
|
|
94
|
+
let output = "";
|
|
95
|
+
for (const line of s.split("\n")) {
|
|
96
|
+
const t = line.trim();
|
|
97
|
+
// transport 的 stream() 已剥掉 "data:" 前缀;SDK/原生流则带前缀。两种都认。
|
|
98
|
+
const raw = t.startsWith("data:") ? t.slice(5).trim() : t;
|
|
99
|
+
if (!raw || raw === "[DONE]") continue;
|
|
100
|
+
try {
|
|
101
|
+
const j = JSON.parse(raw);
|
|
102
|
+
if (j?.usage) usage = j.usage;
|
|
103
|
+
const piece = j?.choices?.[0]?.delta || j?.choices?.[0]?.message;
|
|
104
|
+
if (piece) output += JSON.stringify(piece);
|
|
105
|
+
} catch {}
|
|
106
|
+
}
|
|
107
|
+
const exact = exactOutputTokens(usage);
|
|
108
|
+
return exact ? { tokens: exact, estimated: false } : { tokens: Math.ceil(output.length / 4), estimated: true };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function append(row) {
|
|
112
|
+
try {
|
|
113
|
+
await mkdir(logDir(), { recursive: true });
|
|
114
|
+
await appendFile(join(logDir(), FILE), JSON.stringify(row) + "\n");
|
|
115
|
+
} catch {}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function event(row) {
|
|
119
|
+
try { appendEvent({ ...row, type: "cline-model-usage" }); } catch {}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// free 模型才走「限额恢复 → 再次限额」的周期开关;pass 模型不开新周期(其统计由 aggregateUsage 按 24h 窗口切片)。
|
|
123
|
+
function stateFor(accountId, model, now) {
|
|
124
|
+
const k = keyOf(accountId, model);
|
|
125
|
+
const s = states.get(k) || newState();
|
|
126
|
+
if (s.limitedUntil && now >= s.limitedUntil) {
|
|
127
|
+
s.limitedUntil = 0;
|
|
128
|
+
s.currentOutputTokens = 0;
|
|
129
|
+
s.cycleStartAt = now;
|
|
130
|
+
}
|
|
131
|
+
states.set(k, s);
|
|
132
|
+
return s;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 在消费终点直接记账(比包装 Response 可靠:streamToNonStream 已解析出 usage/正文,网关透传已有累积 bytes)。
|
|
136
|
+
export function usageFromData(data) {
|
|
137
|
+
const exact = exactOutputTokens(data?.usage);
|
|
138
|
+
if (exact) return { tokens: exact, estimated: false };
|
|
139
|
+
const msg = data?.choices?.[0]?.message;
|
|
140
|
+
const text = String(msg?.content || "") + String(msg?.reasoning || "");
|
|
141
|
+
if (!text) return { tokens: 0, estimated: false };
|
|
142
|
+
return { tokens: Math.ceil(text.length / 4), estimated: true };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
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);
|
|
150
|
+
if (!model || !Number.isFinite(n) || n <= 0) return null;
|
|
151
|
+
await ensureLoaded();
|
|
152
|
+
const now = Date.now();
|
|
153
|
+
const free = isFreeModel(model);
|
|
154
|
+
const s = stateFor(accountId, model, now);
|
|
155
|
+
s.currentOutputTokens += n;
|
|
156
|
+
const row = {
|
|
157
|
+
type: "output", at: now, accountId: accountId || "unknown", model, modelType: free ? "free" : "pass",
|
|
158
|
+
outputTokens: n, estimated: !!estimated, cycleOutputTokens: s.currentOutputTokens,
|
|
159
|
+
cycleStartAt: s.cycleStartAt, cycleCount: s.completedCycles + 1,
|
|
160
|
+
};
|
|
161
|
+
await append(row);
|
|
162
|
+
event(row);
|
|
163
|
+
return row;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 仅 free 模型会因 daily_limit 被封周期;pass 若收到 limit 也建 limit 行留痕,但不影响其 24h 窗口统计。
|
|
167
|
+
export async function recordLimit({ accountId, model, reason = "daily_limit", status = 429, cooldownMs = 0 } = {}) {
|
|
168
|
+
if (!model) return null;
|
|
169
|
+
await ensureLoaded();
|
|
170
|
+
const now = Date.now();
|
|
171
|
+
const s = stateFor(accountId, model, now);
|
|
172
|
+
if (s.limitedUntil && s.limitedUntil > now) return null;
|
|
173
|
+
const row = {
|
|
174
|
+
type: "limit", at: now, accountId: accountId || "unknown", model, modelType: isFreeModel(model) ? "free" : "pass",
|
|
175
|
+
cycleOutputTokens: s.currentOutputTokens, cycleStartAt: s.cycleStartAt,
|
|
176
|
+
cycleEndAt: now, until: now + Math.max(0, Number(cooldownMs) || 0),
|
|
177
|
+
reason, status, cycleCount: s.completedCycles + 1,
|
|
178
|
+
};
|
|
179
|
+
s.completedCycles += 1;
|
|
180
|
+
s.lastCycleOutputTokens = s.currentOutputTokens;
|
|
181
|
+
s.lastCycleStartAt = s.cycleStartAt;
|
|
182
|
+
s.lastCycleEndAt = now;
|
|
183
|
+
s.lastReason = reason;
|
|
184
|
+
s.currentOutputTokens = 0;
|
|
185
|
+
s.cycleStartAt = 0;
|
|
186
|
+
s.limitedUntil = row.until;
|
|
187
|
+
await append(row);
|
|
188
|
+
event({ ...row, state: "limit" });
|
|
189
|
+
return row;
|
|
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
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { appendEvent } from "../logs.js";
|
|
1
2
|
import { splitModelId, DEFAULT_PROVIDER, joinModelId } from "./model-id.js";
|
|
2
3
|
import { isModelAllowed as stateIsAllowed, loadProviderAllowedModels as stateLoadAllowed, loadProviderAllowAnyModels as stateLoadAllowAny } from "../state.js";
|
|
3
4
|
|
|
@@ -44,18 +45,36 @@ export function createProviderDispatcher(providers = [], opts = {}) {
|
|
|
44
45
|
if (!isAllowedFn(provider.id, raw)) {
|
|
45
46
|
const allowed = getAllowedFn(provider.id) || [];
|
|
46
47
|
const msg = `model not allowed for provider "${provider.id}": "${raw}" — allowed: ${allowed.join(", ") || "(none)"} (use: mslxdff -provider ${provider.id} allowlist add <model>)`;
|
|
48
|
+
appendEvent({ type: "provider-model-state", provider: provider.id, model: body?.model, rawModel: raw, state: "blocked", reason: "allowlist", status: 403 });
|
|
47
49
|
return new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json", "x-mslxdff-allowlist": "1" } });
|
|
48
50
|
}
|
|
49
51
|
const forwarded = raw === body?.model ? body : { ...body, model: raw };
|
|
50
52
|
// ADR-0008:本请求携带瞬时共享 key(shareKeys 由组员侧按 header 解析后传入)。
|
|
51
53
|
const sharedKeys = opts?.shareKeys?.[provider.id];
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
const startedAt = Date.now();
|
|
55
|
+
let res;
|
|
56
|
+
try {
|
|
57
|
+
if (sharedKeys && sharedKeys.length && typeof provider.chatWithKeys === "function") {
|
|
58
|
+
res = await provider.chatWithKeys(forwarded, sharedKeys, opts);
|
|
59
|
+
} else if (provider.id === "workbuddy" && workbuddyUid) {
|
|
60
|
+
res = await provider.chat(forwarded, { ...opts, workbuddyUid });
|
|
61
|
+
} else {
|
|
62
|
+
res = await provider.chat(forwarded, opts);
|
|
63
|
+
}
|
|
64
|
+
} catch (err) {
|
|
65
|
+
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 });
|
|
66
|
+
throw err;
|
|
57
67
|
}
|
|
58
|
-
|
|
68
|
+
appendEvent({
|
|
69
|
+
type: "provider-model-state",
|
|
70
|
+
provider: provider.id,
|
|
71
|
+
model: body?.model,
|
|
72
|
+
rawModel: raw,
|
|
73
|
+
status: res?.status,
|
|
74
|
+
state: res?.status === 429 ? "limited" : res?.ok ? "ok" : "upstream-error",
|
|
75
|
+
durationMs: Date.now() - startedAt,
|
|
76
|
+
});
|
|
77
|
+
return res;
|
|
59
78
|
}
|
|
60
79
|
|
|
61
80
|
// 聚合所有供应商的模型列表;默认供应商(opencode)裸 id,其它带前缀
|
|
@@ -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)
|
|
@@ -216,6 +217,13 @@ export function createRelayPipeline({
|
|
|
216
217
|
// 窗口报表:逐请求落 usage(行形状由 usage/record.js 拥有,含 prompt/total ——
|
|
217
218
|
// state 的 modelStats 只存 completion 的 EMA)。只按 canonical 名记一次,避免双计。
|
|
218
219
|
recordChatUsage({ model: fullId, via, usage, ttfbMs: ttfb, totalMs: total, tps: m.tps }).catch(() => {});
|
|
220
|
+
// cline 旁路记账:流式时 provider 已把账号哈希挂在 upRes 上,这里用消费完的 usage 记一笔。
|
|
221
|
+
// 纯旁路:只调 recordOutput,不改转发/切号/重试;无账号或非 cline 直接跳过。
|
|
222
|
+
if (upRes?.clineAccountId) {
|
|
223
|
+
const clineModel = upRes.clineModel || String(actual).replace(/^cline\//, "");
|
|
224
|
+
const orow = computeOutputRow({ model: clineModel, accountId: upRes.clineAccountId, usage, chars });
|
|
225
|
+
if (orow) recordOutput(orow).catch(() => {});
|
|
226
|
+
}
|
|
219
227
|
} catch {}
|
|
220
228
|
}
|
|
221
229
|
|
|
@@ -100,6 +100,7 @@ export async function setupProviders() {
|
|
|
100
100
|
providers.push(provider);
|
|
101
101
|
console.log(`provider enabled: ${gid} (${keys.length} key${keys.length > 1 ? "s" : ""}) baseUrl=${base || provider.baseUrl} [custom]`);
|
|
102
102
|
appendEvent({ ts: Date.now(), type: "provider-enabled", provider: gid, keys: keys.length, baseUrl: base || provider.baseUrl });
|
|
103
|
+
appendEvent({ type: "provider-state", provider: gid, state: "enabled", keys: keys.length, accounts: auths.length, baseUrl: base || provider.baseUrl });
|
|
103
104
|
} catch (err) {
|
|
104
105
|
console.log(`provider ${gid} failed: ${err?.message || err}`);
|
|
105
106
|
appendEvent({ ts: Date.now(), type: "provider-error", provider: gid, error: String(err?.message || err) });
|
|
@@ -126,6 +127,7 @@ export async function setupProviders() {
|
|
|
126
127
|
providers.push(createWorkbuddyProvider({ apiKeys: wbKeys, auths: wbAuths }));
|
|
127
128
|
console.log(`provider enabled: workbuddy (${wbKeys.length} key${wbKeys.length > 1 ? "s" : ""}) baseUrl=https://copilot.tencent.com (env)`);
|
|
128
129
|
appendEvent({ ts: Date.now(), type: "provider-enabled", provider: "workbuddy", keys: wbKeys.length, baseUrl: "https://copilot.tencent.com" });
|
|
130
|
+
appendEvent({ type: "provider-state", provider: "workbuddy", state: "enabled", keys: wbKeys.length, accounts: wbAuths.length });
|
|
129
131
|
} catch (err) {
|
|
130
132
|
console.log(`provider workbuddy failed: ${err?.message || err}`);
|
|
131
133
|
}
|
|
@@ -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
|
-
|
|
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;
|