mslxdff 0.1.148 → 0.1.150

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.148",
3
+ "version": "0.1.150",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,20 +34,39 @@ function fmtTps(v) {
34
34
  return v == null || !Number.isFinite(v) ? "—" : `${v} tok/s`;
35
35
  }
36
36
 
37
- // 中文字符按 2 列宽算,否则表格错位
38
- function width(s) {
37
+ // 中文字符按 2 列宽算,否则表格错位。
38
+ function width(value) {
39
39
  let w = 0;
40
- for (const ch of String(s)) w += /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/.test(ch) ? 2 : 1;
40
+ for (const ch of String(value ?? "")) w += /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/.test(ch) ? 2 : 1;
41
41
  return w;
42
42
  }
43
43
 
44
- function padW(s, w) {
45
- const t = String(s);
46
- return t + " ".repeat(Math.max(0, w - width(t)));
44
+ function padCell(value, w, align = "left") {
45
+ const text = String(value ?? "");
46
+ const padding = " ".repeat(Math.max(0, w - width(text)));
47
+ return align === "right" ? padding + text : text + padding;
47
48
  }
48
49
 
49
- function rowText(id, r) {
50
- return ` ${padW(id, 30)} ${padW(r.requests, 6)} ${padW(fmtTok(r.promptTokens), 8)} ${padW(fmtTok(r.completionTokens), 10)} ${padW(fmtTok(r.totalTokens), 8)} ${padW(fmtMs(r.avgTtfbMs), 7)} ${padW(fmtMs(r.avgTotalMs), 8)} ${fmtTps(r.avgTps)}`;
50
+ function renderTable(headers, rows, aligns = []) {
51
+ const matrix = [headers, ...rows];
52
+ const widths = headers.map((_, col) => Math.max(...matrix.map((row) => width(row[col]))));
53
+ const rule = (left, middle, right) => left + widths.map((w) => "─".repeat(w + 2)).join(middle) + right;
54
+ const line = (row) => `│${row.map((cell, col) => ` ${padCell(cell, widths[col], aligns[col])} `).join("│")}│`;
55
+ return [
56
+ rule("┌", "┬", "┐"),
57
+ line(headers),
58
+ rule("├", "┼", "┤"),
59
+ ...rows.map(line),
60
+ rule("└", "┴", "┘"),
61
+ ].join("\n");
62
+ }
63
+
64
+ function tokenRow(id, r) {
65
+ return [id, r.requests, fmtTok(r.promptTokens), fmtTok(r.completionTokens), fmtTok(r.reasoningTokens), fmtTok(r.totalTokens)];
66
+ }
67
+
68
+ function performanceRow(id, r) {
69
+ return [id, fmtMs(r.avgTtfbMs), fmtMs(r.avgTotalMs), fmtTps(r.avgTps)];
51
70
  }
52
71
 
53
72
  export function renderStats(report, { hours = 24, model = null } = {}) {
@@ -59,15 +78,30 @@ export function renderStats(report, { hours = 24, model = null } = {}) {
59
78
  lines.push("说明:-chat 直连 mimo/big-pickle 不经网关,不计入本表");
60
79
  return lines.join("\n");
61
80
  }
62
- lines.push(`模型用量(近 ${hours}h · 成功请求 ${totals.requests} 次 · ${models.length} 个模型)`);
63
- lines.push(` ${padW("模型", 30)} ${padW("请求", 6)} ${padW("prompt", 8)} ${padW("输出", 10)} ${padW("合计", 8)} ${padW("首字", 7)} ${padW("总耗时", 8)} 速度`);
64
- for (const m of models) lines.push(rowText(m.id, m));
65
- lines.push(` ${"-".repeat(76)}`);
66
- lines.push(rowText("合计", totals));
67
- if (totals.reasoningTokens > 0) lines.push(` 其中思考 tokens:${fmtTok(totals.reasoningTokens)}`);
81
+
82
+ const right = "right";
83
+ const tokenRows = [...models.map((m) => tokenRow(m.id, m)), tokenRow("合计", totals)];
84
+ const performanceRows = [...models.map((m) => performanceRow(m.id, m)), performanceRow("合计", totals)];
85
+ lines.push(`模型用量报告(近 ${hours}h${model ? ` · 筛选 ${model}` : ""})`);
86
+ lines.push(`成功请求:${totals.requests} 次 · 模型:${models.length} 个`);
87
+ lines.push("");
88
+ lines.push("Token 用量");
89
+ lines.push(renderTable(["模型", "请求", "输入", "输出", "思考", "合计"], tokenRows, ["left", right, right, right, right, right]));
90
+ lines.push("");
91
+ lines.push("响应性能");
92
+ lines.push(renderTable(["模型", "首字", "总耗时", "速度"], performanceRows, ["left", right, right, right]));
68
93
  lines.push("");
69
94
  lines.push("说明:速度 = 输出 tokens ÷ 生成耗时(总耗时−首字),按窗口加权;只统计成功请求。");
70
- lines.push(` -chat 直连 mimo/big-pickle 不经 8989 网关,不计入。数据保留 ${usageKeepDays()} 天,mslxdff -stats --hours 1|--json|--model <id> 可调。`);
95
+ lines.push("范围:只含经 8989 网关的成功请求;不含失败请求和 -chat 直连。");
96
+ const keepDays = usageKeepDays();
97
+ if (hours > keepDays * 24) {
98
+ lines.push(`警告:查询 ${hours}h 超过数据保留 ${keepDays} 天,历史可能不完整。`);
99
+ } else {
100
+ lines.push(`数据保留 ${keepDays} 天;更早的历史已删除。`);
101
+ }
102
+ lines.push("明细口径:本表按模型聚合,不展开 via、interrupted 和单次 tps。");
103
+ lines.push("数值使用 k/M 缩写;需要精确值或机器处理请加 --json。");
104
+ lines.push("可调:mslxdff -stats --hours 1 | --model <id> | --json");
71
105
  return lines.join("\n");
72
106
  }
73
107
 
@@ -85,4 +119,4 @@ export async function handleStats(args) {
85
119
  }
86
120
  console.log(renderStats(report, { hours, model }));
87
121
  return true;
88
- }
122
+ }
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
  }
@@ -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;
@@ -213,6 +276,10 @@ export function createChatService({
213
276
  }
214
277
  const normalized = unwrapData(raw);
215
278
  normalized.model = model;
279
+ // 非流式终点:正文估算记账(上游有 usage 时 streamToNonStream 路径才拿得到精确值;此处 raw 无流)
280
+ const m2 = normalized?.choices?.[0]?.message || {};
281
+ const outLen2 = String(m2.content || "").length + String(m2.reasoning || "").length;
282
+ if (outLen2 > 0) track(model)({ tokens: Math.ceil(outLen2 / 4), estimated: true });
216
283
  return new Response(JSON.stringify(normalized), { status: 200, headers: { "Content-Type": "application/json" } });
217
284
  } catch (err) {
218
285
  if (netAttempt < 2 && String(err?.message || "").toLowerCase().includes("timed out")) { await sleep(300); continue; }
@@ -0,0 +1,170 @@
1
+ // Cline 账号 × 模型额度周期统计。
2
+ // 目标:记录“额度恢复后到下一次限流”之间正常响应累计的 output/completion tokens。
3
+ // 不记录 token、邮箱、prompt、响应正文;账号只使用调用方传入的稳定哈希。
4
+ import { appendFile, mkdir, readFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import { logDir } from "../../logs.js";
7
+ import { appendEvent } from "../../logs.js";
8
+
9
+ const FILE = "cline-usage.jsonl";
10
+ const states = new Map();
11
+ let loaded;
12
+
13
+ function keyOf(accountId, model) {
14
+ return `${String(accountId || "unknown")}|${String(model || "unknown")}`;
15
+ }
16
+
17
+ function newState() {
18
+ return {
19
+ currentOutputTokens: 0,
20
+ cycleStartAt: Date.now(),
21
+ limitedUntil: 0,
22
+ completedCycles: 0,
23
+ lastCycleOutputTokens: 0,
24
+ lastCycleStartAt: null,
25
+ lastCycleEndAt: null,
26
+ lastReason: null,
27
+ };
28
+ }
29
+
30
+ async function ensureLoaded() {
31
+ if (!loaded) {
32
+ loaded = (async () => {
33
+ let text = "";
34
+ try { text = await readFile(join(logDir(), FILE), "utf8"); } catch { return; }
35
+ for (const line of text.split("\n")) {
36
+ if (!line) continue;
37
+ try {
38
+ const row = JSON.parse(line);
39
+ const k = keyOf(row.accountId, row.model);
40
+ const s = states.get(k) || newState();
41
+ if (row.type === "output") {
42
+ s.currentOutputTokens = Number(row.cycleOutputTokens) || 0;
43
+ s.cycleStartAt = Number(row.cycleStartAt) || Number(row.at) || s.cycleStartAt;
44
+ s.limitedUntil = 0;
45
+ } else if (row.type === "limit") {
46
+ s.completedCycles = Math.max(0, (Number(row.cycleCount) || 1) - 1);
47
+ s.lastCycleOutputTokens = Number(row.cycleOutputTokens) || 0;
48
+ s.lastCycleStartAt = Number(row.cycleStartAt) || null;
49
+ s.lastCycleEndAt = Number(row.at) || null;
50
+ s.lastReason = row.reason || null;
51
+ s.currentOutputTokens = 0;
52
+ s.cycleStartAt = 0;
53
+ s.limitedUntil = Number(row.until) || 0;
54
+ }
55
+ states.set(k, s);
56
+ } catch {}
57
+ }
58
+ })();
59
+ }
60
+ return loaded;
61
+ }
62
+
63
+ export function exactOutputTokens(usage) {
64
+ const n = Number(usage?.completion_tokens ?? usage?.completionTokens ?? usage?.output_tokens ?? usage?.outputTokens);
65
+ return Number.isFinite(n) && n > 0 ? n : 0;
66
+ }
67
+
68
+ // 上游有时不给 usage(本次真实 Cline 响应就是如此);按输出正文估算并明确标记 estimated。
69
+ function parseOutput(text) {
70
+ const s = String(text || "");
71
+ let parsed = null;
72
+ try { parsed = JSON.parse(s); } catch {}
73
+ if (parsed) {
74
+ const exact = exactOutputTokens(parsed?.usage || parsed);
75
+ if (exact) return { tokens: exact, estimated: false };
76
+ const output = parsed?.choices?.[0]?.message || parsed?.choices?.[0]?.delta || parsed?.output || parsed;
77
+ return { tokens: Math.ceil(JSON.stringify(output || "").length / 4), estimated: true };
78
+ }
79
+ let usage = null;
80
+ let output = "";
81
+ for (const line of s.split("\n")) {
82
+ const t = line.trim();
83
+ // transport 的 stream() 已剥掉 "data:" 前缀;SDK/原生流则带前缀。两种都认。
84
+ const raw = t.startsWith("data:") ? t.slice(5).trim() : t;
85
+ if (!raw || raw === "[DONE]") continue;
86
+ try {
87
+ const j = JSON.parse(raw);
88
+ if (j?.usage) usage = j.usage;
89
+ const piece = j?.choices?.[0]?.delta || j?.choices?.[0]?.message;
90
+ if (piece) output += JSON.stringify(piece);
91
+ } catch {}
92
+ }
93
+ const exact = exactOutputTokens(usage);
94
+ return exact ? { tokens: exact, estimated: false } : { tokens: Math.ceil(output.length / 4), estimated: true };
95
+ }
96
+
97
+ async function append(row) {
98
+ try {
99
+ await mkdir(logDir(), { recursive: true });
100
+ await appendFile(join(logDir(), FILE), JSON.stringify(row) + "\n");
101
+ } catch {}
102
+ }
103
+
104
+ function event(row) {
105
+ try { appendEvent({ ...row, type: "cline-model-usage" }); } catch {}
106
+ }
107
+
108
+ function stateFor(accountId, model, now) {
109
+ const k = keyOf(accountId, model);
110
+ const s = states.get(k) || newState();
111
+ if (s.limitedUntil && now >= s.limitedUntil) {
112
+ s.limitedUntil = 0;
113
+ s.currentOutputTokens = 0;
114
+ s.cycleStartAt = now;
115
+ }
116
+ states.set(k, s);
117
+ return s;
118
+ }
119
+ // 在消费终点直接记账(比包装 Response 可靠:streamToNonStream 已解析出 usage/正文,网关透传已有累积 bytes)。
120
+ export function usageFromData(data) {
121
+ const exact = exactOutputTokens(data?.usage);
122
+ if (exact) return { tokens: exact, estimated: false };
123
+ const msg = data?.choices?.[0]?.message;
124
+ const text = String(msg?.content || "") + String(msg?.reasoning || "");
125
+ if (!text) return { tokens: 0, estimated: false };
126
+ return { tokens: Math.ceil(text.length / 4), estimated: true };
127
+ }
128
+
129
+
130
+ export async function recordOutput({ accountId, model, tokens, estimated = false } = {}) {
131
+ const n = Number(tokens);
132
+ if (!model || !Number.isFinite(n) || n <= 0) return null;
133
+ await ensureLoaded();
134
+ const now = Date.now();
135
+ const s = stateFor(accountId, model, now);
136
+ s.currentOutputTokens += n;
137
+ const row = {
138
+ type: "output", at: now, accountId: accountId || "unknown", model,
139
+ outputTokens: n, estimated: !!estimated, cycleOutputTokens: s.currentOutputTokens,
140
+ cycleStartAt: s.cycleStartAt, cycleCount: s.completedCycles + 1,
141
+ };
142
+ await append(row);
143
+ event(row);
144
+ return row;
145
+ }
146
+
147
+ export async function recordLimit({ accountId, model, reason = "daily_limit", status = 429, cooldownMs = 0 } = {}) {
148
+ if (!model) return null;
149
+ await ensureLoaded();
150
+ const now = Date.now();
151
+ const s = stateFor(accountId, model, now);
152
+ if (s.limitedUntil && s.limitedUntil > now) return null;
153
+ const row = {
154
+ type: "limit", at: now, accountId: accountId || "unknown", model,
155
+ cycleOutputTokens: s.currentOutputTokens, cycleStartAt: s.cycleStartAt,
156
+ cycleEndAt: now, until: now + Math.max(0, Number(cooldownMs) || 0),
157
+ reason, status, cycleCount: s.completedCycles + 1,
158
+ };
159
+ s.completedCycles += 1;
160
+ s.lastCycleOutputTokens = s.currentOutputTokens;
161
+ s.lastCycleStartAt = s.cycleStartAt;
162
+ s.lastCycleEndAt = now;
163
+ s.lastReason = reason;
164
+ s.currentOutputTokens = 0;
165
+ s.cycleStartAt = 0;
166
+ s.limitedUntil = row.until;
167
+ await append(row);
168
+ event({ ...row, state: "limit" });
169
+ return row;
170
+ }
@@ -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,其它带前缀
@@ -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
  }