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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.149",
3
+ "version": "0.1.151",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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");
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.dead) continue;
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)) { account.dead = true; throw new Error("invalid_grant"); }
139
- account.cooldownUntil = now + 60 * 1000; throw new Error("refresh_failed");
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.dead) continue;
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
- acc.cooldownUntil = 0;
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
- if (cur) { cur.cooldownUntil = Date.now() + cooldownMs; cur.accessToken = null; cur.expiry = 0; }
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 hasOther = pool.some((a) => !a.cooldownUntil || a.cooldownUntil <= Date.now());
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
- if (cur) { cur.cooldownUntil = Date.now() + 30 * 1000; cur.accessToken = null; cur.expiry = 0; }
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) 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
+ }
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
- 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,
@@ -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
- if (sharedKeys && sharedKeys.length && typeof provider.chatWithKeys === "function") {
53
- return provider.chatWithKeys(forwarded, sharedKeys, opts);
54
- }
55
- if (provider.id === "workbuddy" && workbuddyUid) {
56
- return provider.chat(forwarded, { ...opts, workbuddyUid });
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
- return provider.chat(forwarded, opts);
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
- 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;