mslxdff 0.1.149 → 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
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
|
|
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;
|
|
@@ -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
|
-
|
|
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,其它带前缀
|
|
@@ -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
|
}
|