@itssimplereally/opencode-kimicode-auth 0.1.0
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/LICENSE +22 -0
- package/README.md +87 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/src/constants.d.ts +69 -0
- package/dist/src/constants.d.ts.map +1 -0
- package/dist/src/constants.js +207 -0
- package/dist/src/constants.js.map +1 -0
- package/dist/src/kimi/oauth.d.ts +64 -0
- package/dist/src/kimi/oauth.d.ts.map +1 -0
- package/dist/src/kimi/oauth.js +130 -0
- package/dist/src/kimi/oauth.js.map +1 -0
- package/dist/src/plugin/accounts.d.ts +167 -0
- package/dist/src/plugin/accounts.d.ts.map +1 -0
- package/dist/src/plugin/accounts.js +843 -0
- package/dist/src/plugin/accounts.js.map +1 -0
- package/dist/src/plugin/auth.d.ts +13 -0
- package/dist/src/plugin/auth.d.ts.map +1 -0
- package/dist/src/plugin/auth.js +26 -0
- package/dist/src/plugin/auth.js.map +1 -0
- package/dist/src/plugin/cache.d.ts +14 -0
- package/dist/src/plugin/cache.d.ts.map +1 -0
- package/dist/src/plugin/cache.js +56 -0
- package/dist/src/plugin/cache.js.map +1 -0
- package/dist/src/plugin/cli.d.ts +21 -0
- package/dist/src/plugin/cli.d.ts.map +1 -0
- package/dist/src/plugin/cli.js +98 -0
- package/dist/src/plugin/cli.js.map +1 -0
- package/dist/src/plugin/config/index.d.ts +16 -0
- package/dist/src/plugin/config/index.d.ts.map +1 -0
- package/dist/src/plugin/config/index.js +16 -0
- package/dist/src/plugin/config/index.js.map +1 -0
- package/dist/src/plugin/config/loader.d.ts +36 -0
- package/dist/src/plugin/config/loader.d.ts.map +1 -0
- package/dist/src/plugin/config/loader.js +182 -0
- package/dist/src/plugin/config/loader.js.map +1 -0
- package/dist/src/plugin/config/models.d.ts +18 -0
- package/dist/src/plugin/config/models.d.ts.map +1 -0
- package/dist/src/plugin/config/models.js +26 -0
- package/dist/src/plugin/config/models.js.map +1 -0
- package/dist/src/plugin/config/schema.d.ts +107 -0
- package/dist/src/plugin/config/schema.d.ts.map +1 -0
- package/dist/src/plugin/config/schema.js +282 -0
- package/dist/src/plugin/config/schema.js.map +1 -0
- package/dist/src/plugin/config/updater.d.ts +55 -0
- package/dist/src/plugin/config/updater.d.ts.map +1 -0
- package/dist/src/plugin/config/updater.js +154 -0
- package/dist/src/plugin/config/updater.js.map +1 -0
- package/dist/src/plugin/debug.d.ts +92 -0
- package/dist/src/plugin/debug.d.ts.map +1 -0
- package/dist/src/plugin/debug.js +406 -0
- package/dist/src/plugin/debug.js.map +1 -0
- package/dist/src/plugin/errors.d.ts +28 -0
- package/dist/src/plugin/errors.d.ts.map +1 -0
- package/dist/src/plugin/errors.js +42 -0
- package/dist/src/plugin/errors.js.map +1 -0
- package/dist/src/plugin/fingerprint.d.ts +41 -0
- package/dist/src/plugin/fingerprint.d.ts.map +1 -0
- package/dist/src/plugin/fingerprint.js +94 -0
- package/dist/src/plugin/fingerprint.js.map +1 -0
- package/dist/src/plugin/logger.d.ts +54 -0
- package/dist/src/plugin/logger.d.ts.map +1 -0
- package/dist/src/plugin/logger.js +120 -0
- package/dist/src/plugin/logger.js.map +1 -0
- package/dist/src/plugin/recovery/constants.d.ts +26 -0
- package/dist/src/plugin/recovery/constants.d.ts.map +1 -0
- package/dist/src/plugin/recovery/constants.js +47 -0
- package/dist/src/plugin/recovery/constants.js.map +1 -0
- package/dist/src/plugin/recovery/index.d.ts +16 -0
- package/dist/src/plugin/recovery/index.d.ts.map +1 -0
- package/dist/src/plugin/recovery/index.js +16 -0
- package/dist/src/plugin/recovery/index.js.map +1 -0
- package/dist/src/plugin/recovery/storage.d.ts +24 -0
- package/dist/src/plugin/recovery/storage.d.ts.map +1 -0
- package/dist/src/plugin/recovery/storage.js +354 -0
- package/dist/src/plugin/recovery/storage.js.map +1 -0
- package/dist/src/plugin/recovery/types.d.ts +116 -0
- package/dist/src/plugin/recovery/types.d.ts.map +1 -0
- package/dist/src/plugin/recovery/types.js +6 -0
- package/dist/src/plugin/recovery/types.js.map +1 -0
- package/dist/src/plugin/recovery.d.ts +63 -0
- package/dist/src/plugin/recovery.d.ts.map +1 -0
- package/dist/src/plugin/recovery.js +331 -0
- package/dist/src/plugin/recovery.js.map +1 -0
- package/dist/src/plugin/refresh-queue.d.ts +101 -0
- package/dist/src/plugin/refresh-queue.d.ts.map +1 -0
- package/dist/src/plugin/refresh-queue.js +248 -0
- package/dist/src/plugin/refresh-queue.js.map +1 -0
- package/dist/src/plugin/rotation.d.ts +169 -0
- package/dist/src/plugin/rotation.d.ts.map +1 -0
- package/dist/src/plugin/rotation.js +328 -0
- package/dist/src/plugin/rotation.js.map +1 -0
- package/dist/src/plugin/storage.d.ts +90 -0
- package/dist/src/plugin/storage.d.ts.map +1 -0
- package/dist/src/plugin/storage.js +450 -0
- package/dist/src/plugin/storage.js.map +1 -0
- package/dist/src/plugin/token.d.ts +19 -0
- package/dist/src/plugin/token.d.ts.map +1 -0
- package/dist/src/plugin/token.js +112 -0
- package/dist/src/plugin/token.js.map +1 -0
- package/dist/src/plugin/types.d.ts +97 -0
- package/dist/src/plugin/types.d.ts.map +1 -0
- package/dist/src/plugin/types.js +1 -0
- package/dist/src/plugin/types.js.map +1 -0
- package/dist/src/plugin/version.d.ts +14 -0
- package/dist/src/plugin/version.d.ts.map +1 -0
- package/dist/src/plugin/version.js +20 -0
- package/dist/src/plugin/version.js.map +1 -0
- package/dist/src/plugin.d.ts +5 -0
- package/dist/src/plugin.d.ts.map +1 -0
- package/dist/src/plugin.js +1077 -0
- package/dist/src/plugin.js.map +1 -0
- package/package.json +55 -0
|
@@ -0,0 +1,843 @@
|
|
|
1
|
+
import { loadAccounts, saveAccounts } from "./storage";
|
|
2
|
+
import { getHealthTracker, getTokenTracker, selectHybridAccount } from "./rotation";
|
|
3
|
+
import { coerceFingerprint, generateFingerprint, MAX_FINGERPRINT_HISTORY } from "./fingerprint";
|
|
4
|
+
import { debugLogToFile } from "./debug";
|
|
5
|
+
const QUOTA_EXHAUSTED_BACKOFFS = [60_000, 300_000, 1_800_000, 7_200_000];
|
|
6
|
+
const RATE_LIMIT_EXCEEDED_BACKOFF = 30_000;
|
|
7
|
+
// Increased from 15s to 45s base + jitter to reduce retry pressure on capacity errors
|
|
8
|
+
const MODEL_CAPACITY_EXHAUSTED_BASE_BACKOFF = 45_000;
|
|
9
|
+
const MODEL_CAPACITY_EXHAUSTED_JITTER_MAX = 30_000; // ±15s jitter range
|
|
10
|
+
const SERVER_ERROR_BACKOFF = 20_000;
|
|
11
|
+
const UNKNOWN_BACKOFF = 60_000;
|
|
12
|
+
const MIN_BACKOFF_MS = 2_000;
|
|
13
|
+
/**
|
|
14
|
+
* Generate a random jitter value for backoff timing.
|
|
15
|
+
* Helps prevent thundering herd problem when multiple clients retry simultaneously.
|
|
16
|
+
*/
|
|
17
|
+
function generateJitter(maxJitterMs) {
|
|
18
|
+
return Math.random() * maxJitterMs - (maxJitterMs / 2);
|
|
19
|
+
}
|
|
20
|
+
export function parseRateLimitReason(reason, message, status) {
|
|
21
|
+
// 1. Status Code Checks (Rust parity)
|
|
22
|
+
// 529 = Site Overloaded, 503 = Service Unavailable -> Capacity issues
|
|
23
|
+
if (status === 529 || status === 503)
|
|
24
|
+
return "MODEL_CAPACITY_EXHAUSTED";
|
|
25
|
+
// 500 = Internal Server Error -> Treat as Server Error (soft wait)
|
|
26
|
+
if (status === 500)
|
|
27
|
+
return "SERVER_ERROR";
|
|
28
|
+
// 2. Explicit Reason String
|
|
29
|
+
if (reason) {
|
|
30
|
+
switch (reason.toUpperCase()) {
|
|
31
|
+
case "QUOTA_EXHAUSTED": return "QUOTA_EXHAUSTED";
|
|
32
|
+
case "RATE_LIMIT_EXCEEDED": return "RATE_LIMIT_EXCEEDED";
|
|
33
|
+
case "MODEL_CAPACITY_EXHAUSTED": return "MODEL_CAPACITY_EXHAUSTED";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// 3. Message Text Scanning (Rust Regex parity)
|
|
37
|
+
if (message) {
|
|
38
|
+
const lower = message.toLowerCase();
|
|
39
|
+
// Capacity / Overloaded (Transient) - Check FIRST before "exhausted"
|
|
40
|
+
if (lower.includes("capacity") || lower.includes("overloaded") || lower.includes("resource exhausted")) {
|
|
41
|
+
return "MODEL_CAPACITY_EXHAUSTED";
|
|
42
|
+
}
|
|
43
|
+
// RPM / TPM (Short Wait)
|
|
44
|
+
// "per minute", "rate limit", "too many requests"
|
|
45
|
+
// "presque" (French: almost) - retained for i18n parity with Rust reference
|
|
46
|
+
if (lower.includes("per minute") || lower.includes("rate limit") || lower.includes("too many requests") || lower.includes("presque")) {
|
|
47
|
+
return "RATE_LIMIT_EXCEEDED";
|
|
48
|
+
}
|
|
49
|
+
// Quota (Long Wait)
|
|
50
|
+
if (lower.includes("exhausted") || lower.includes("quota")) {
|
|
51
|
+
return "QUOTA_EXHAUSTED";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Default fallback for 429 without clearer info
|
|
55
|
+
if (status === 429) {
|
|
56
|
+
return "UNKNOWN";
|
|
57
|
+
}
|
|
58
|
+
return "UNKNOWN";
|
|
59
|
+
}
|
|
60
|
+
export function calculateBackoffMs(reason, consecutiveFailures, retryAfterMs) {
|
|
61
|
+
// Respect explicit Retry-After header if reasonable
|
|
62
|
+
if (retryAfterMs && retryAfterMs > 0) {
|
|
63
|
+
// Rust uses 2s min buffer, we keep 2s
|
|
64
|
+
return Math.max(retryAfterMs, MIN_BACKOFF_MS);
|
|
65
|
+
}
|
|
66
|
+
switch (reason) {
|
|
67
|
+
case "QUOTA_EXHAUSTED": {
|
|
68
|
+
const index = Math.min(consecutiveFailures, QUOTA_EXHAUSTED_BACKOFFS.length - 1);
|
|
69
|
+
return QUOTA_EXHAUSTED_BACKOFFS[index] ?? UNKNOWN_BACKOFF;
|
|
70
|
+
}
|
|
71
|
+
case "RATE_LIMIT_EXCEEDED":
|
|
72
|
+
return RATE_LIMIT_EXCEEDED_BACKOFF; // 30s
|
|
73
|
+
case "MODEL_CAPACITY_EXHAUSTED":
|
|
74
|
+
// Apply jitter to prevent thundering herd on capacity errors
|
|
75
|
+
return MODEL_CAPACITY_EXHAUSTED_BASE_BACKOFF + generateJitter(MODEL_CAPACITY_EXHAUSTED_JITTER_MAX);
|
|
76
|
+
case "SERVER_ERROR":
|
|
77
|
+
return SERVER_ERROR_BACKOFF; // 20s
|
|
78
|
+
case "UNKNOWN":
|
|
79
|
+
default:
|
|
80
|
+
return UNKNOWN_BACKOFF; // 60s
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function nowMs() {
|
|
84
|
+
return Date.now();
|
|
85
|
+
}
|
|
86
|
+
function clampNonNegativeInt(value, fallback) {
|
|
87
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
88
|
+
return fallback;
|
|
89
|
+
}
|
|
90
|
+
return value < 0 ? 0 : Math.floor(value);
|
|
91
|
+
}
|
|
92
|
+
function getQuotaKey(_family, _headerStyle, model) {
|
|
93
|
+
if (model) {
|
|
94
|
+
return `kimi:${model}`;
|
|
95
|
+
}
|
|
96
|
+
return "kimi";
|
|
97
|
+
}
|
|
98
|
+
function isRateLimitedForQuotaKey(account, key) {
|
|
99
|
+
const resetTime = account.rateLimitResetTimes[key];
|
|
100
|
+
return resetTime !== undefined && nowMs() < resetTime;
|
|
101
|
+
}
|
|
102
|
+
function isRateLimitedForFamily(account, _family, model) {
|
|
103
|
+
clearExpiredRateLimits(account);
|
|
104
|
+
// Check model-specific quota first
|
|
105
|
+
if (model) {
|
|
106
|
+
if (isRateLimitedForQuotaKey(account, `kimi:${model}`)) {
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Then check base quota
|
|
111
|
+
return isRateLimitedForQuotaKey(account, "kimi");
|
|
112
|
+
}
|
|
113
|
+
function isRateLimitedForHeaderStyle(account, family, _headerStyle, model) {
|
|
114
|
+
return isRateLimitedForFamily(account, family, model);
|
|
115
|
+
}
|
|
116
|
+
function clearExpiredRateLimits(account) {
|
|
117
|
+
const now = nowMs();
|
|
118
|
+
const keys = Object.keys(account.rateLimitResetTimes);
|
|
119
|
+
for (const key of keys) {
|
|
120
|
+
const resetTime = account.rateLimitResetTimes[key];
|
|
121
|
+
if (resetTime !== undefined && now >= resetTime) {
|
|
122
|
+
delete account.rateLimitResetTimes[key];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Resolve the quota group for soft quota checks.
|
|
128
|
+
*
|
|
129
|
+
* When a model string is available, we use it directly as the quota group.
|
|
130
|
+
* When model is null/undefined, we fall back to "kimi" as the default group.
|
|
131
|
+
*
|
|
132
|
+
* @param _family - The model family ("kimi")
|
|
133
|
+
* @param model - Optional model string for precise resolution
|
|
134
|
+
* @returns The QuotaGroup to use for soft quota checks
|
|
135
|
+
*/
|
|
136
|
+
export function resolveQuotaGroup(_family, model) {
|
|
137
|
+
if (model) {
|
|
138
|
+
return model;
|
|
139
|
+
}
|
|
140
|
+
return "kimi";
|
|
141
|
+
}
|
|
142
|
+
function isOverSoftQuotaThreshold(account, family, thresholdPercent, cacheTtlMs, model) {
|
|
143
|
+
if (thresholdPercent >= 100)
|
|
144
|
+
return false;
|
|
145
|
+
if (!account.cachedQuota)
|
|
146
|
+
return false;
|
|
147
|
+
if (account.cachedQuotaUpdatedAt == null)
|
|
148
|
+
return false;
|
|
149
|
+
const age = nowMs() - account.cachedQuotaUpdatedAt;
|
|
150
|
+
if (age > cacheTtlMs)
|
|
151
|
+
return false;
|
|
152
|
+
const quotaGroup = resolveQuotaGroup(family, model);
|
|
153
|
+
const groupData = account.cachedQuota[quotaGroup];
|
|
154
|
+
if (groupData?.remainingFraction == null)
|
|
155
|
+
return false;
|
|
156
|
+
const remainingFraction = Math.max(0, Math.min(1, groupData.remainingFraction));
|
|
157
|
+
const usedPercent = (1 - remainingFraction) * 100;
|
|
158
|
+
const isOverThreshold = usedPercent >= thresholdPercent;
|
|
159
|
+
if (isOverThreshold) {
|
|
160
|
+
const accountLabel = account.email || `Account ${account.index + 1}`;
|
|
161
|
+
debugLogToFile(`[SoftQuota] Skipping ${accountLabel}: ${quotaGroup} usage ${usedPercent.toFixed(1)}% >= threshold ${thresholdPercent}%` +
|
|
162
|
+
(groupData.resetTime ? ` (resets: ${groupData.resetTime})` : ''));
|
|
163
|
+
}
|
|
164
|
+
return isOverThreshold;
|
|
165
|
+
}
|
|
166
|
+
export function computeSoftQuotaCacheTtlMs(ttlConfig, refreshIntervalMinutes) {
|
|
167
|
+
if (ttlConfig === "auto") {
|
|
168
|
+
return Math.max(2 * refreshIntervalMinutes, 10) * 60 * 1000;
|
|
169
|
+
}
|
|
170
|
+
return ttlConfig * 60 * 1000;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* In-memory multi-account manager with sticky account selection.
|
|
174
|
+
*
|
|
175
|
+
* Uses the same account until it hits a rate limit (429), then switches.
|
|
176
|
+
* Rate limits are tracked per quota key ("kimi" base or "kimi:<model>").
|
|
177
|
+
*
|
|
178
|
+
* Source of truth for the pool is `kimicode-accounts.json`.
|
|
179
|
+
*/
|
|
180
|
+
export class AccountManager {
|
|
181
|
+
accounts = [];
|
|
182
|
+
cursor = 0;
|
|
183
|
+
currentAccountIndexByFamily = {
|
|
184
|
+
kimi: -1,
|
|
185
|
+
};
|
|
186
|
+
sessionOffsetApplied = {
|
|
187
|
+
kimi: false,
|
|
188
|
+
};
|
|
189
|
+
lastToastAccountIndex = -1;
|
|
190
|
+
lastToastTime = 0;
|
|
191
|
+
savePending = false;
|
|
192
|
+
saveTimeout = null;
|
|
193
|
+
savePromiseResolvers = [];
|
|
194
|
+
static async loadFromDisk(authFallback) {
|
|
195
|
+
const stored = await loadAccounts();
|
|
196
|
+
return new AccountManager(authFallback, stored);
|
|
197
|
+
}
|
|
198
|
+
constructor(authFallback, stored) {
|
|
199
|
+
const authParts = authFallback ? { refreshToken: authFallback.refresh } : null;
|
|
200
|
+
if (stored && stored.accounts.length === 0) {
|
|
201
|
+
this.accounts = [];
|
|
202
|
+
this.cursor = 0;
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (stored && stored.accounts.length > 0) {
|
|
206
|
+
const baseNow = nowMs();
|
|
207
|
+
this.accounts = stored.accounts
|
|
208
|
+
.map((acc, index) => {
|
|
209
|
+
if (!acc.refreshToken || typeof acc.refreshToken !== "string") {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
const matchesFallback = !!(authFallback &&
|
|
213
|
+
authParts &&
|
|
214
|
+
authParts.refreshToken &&
|
|
215
|
+
acc.refreshToken === authParts.refreshToken);
|
|
216
|
+
return {
|
|
217
|
+
index,
|
|
218
|
+
email: acc.email,
|
|
219
|
+
addedAt: clampNonNegativeInt(acc.addedAt, baseNow),
|
|
220
|
+
lastUsed: clampNonNegativeInt(acc.lastUsed, 0),
|
|
221
|
+
parts: {
|
|
222
|
+
refreshToken: acc.refreshToken,
|
|
223
|
+
},
|
|
224
|
+
access: matchesFallback ? authFallback?.access : undefined,
|
|
225
|
+
expires: matchesFallback ? authFallback?.expires : undefined,
|
|
226
|
+
enabled: acc.enabled !== false,
|
|
227
|
+
rateLimitResetTimes: acc.rateLimitResetTimes ?? {},
|
|
228
|
+
lastSwitchReason: acc.lastSwitchReason,
|
|
229
|
+
coolingDownUntil: acc.coolingDownUntil,
|
|
230
|
+
cooldownReason: acc.cooldownReason,
|
|
231
|
+
touchedForQuota: {},
|
|
232
|
+
fingerprint: coerceFingerprint(acc.fingerprint),
|
|
233
|
+
fingerprintHistory: acc.fingerprintHistory ?? [],
|
|
234
|
+
cachedQuota: acc.cachedQuota,
|
|
235
|
+
cachedQuotaUpdatedAt: acc.cachedQuotaUpdatedAt,
|
|
236
|
+
};
|
|
237
|
+
})
|
|
238
|
+
.filter((a) => a !== null);
|
|
239
|
+
this.cursor = clampNonNegativeInt(stored.activeIndex, 0);
|
|
240
|
+
if (this.accounts.length > 0) {
|
|
241
|
+
this.cursor = this.cursor % this.accounts.length;
|
|
242
|
+
const defaultIndex = this.cursor;
|
|
243
|
+
this.currentAccountIndexByFamily.kimi = clampNonNegativeInt(stored.activeIndexByFamily?.kimi, defaultIndex) % this.accounts.length;
|
|
244
|
+
}
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
// If we have stored accounts, check if we need to add the current auth
|
|
248
|
+
if (authFallback && this.accounts.length > 0) {
|
|
249
|
+
const authParts = { refreshToken: authFallback.refresh };
|
|
250
|
+
const hasMatching = this.accounts.some(acc => acc.parts.refreshToken === authParts.refreshToken);
|
|
251
|
+
if (!hasMatching && authParts.refreshToken) {
|
|
252
|
+
const now = nowMs();
|
|
253
|
+
const newAccount = {
|
|
254
|
+
index: this.accounts.length,
|
|
255
|
+
email: undefined,
|
|
256
|
+
addedAt: now,
|
|
257
|
+
lastUsed: 0,
|
|
258
|
+
parts: authParts,
|
|
259
|
+
access: authFallback.access,
|
|
260
|
+
expires: authFallback.expires,
|
|
261
|
+
enabled: true,
|
|
262
|
+
rateLimitResetTimes: {},
|
|
263
|
+
touchedForQuota: {},
|
|
264
|
+
};
|
|
265
|
+
this.accounts.push(newAccount);
|
|
266
|
+
// Update indices to include the new account
|
|
267
|
+
this.currentAccountIndexByFamily.kimi = Math.min(this.currentAccountIndexByFamily.kimi, this.accounts.length - 1);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (authFallback) {
|
|
271
|
+
const parts = { refreshToken: authFallback.refresh };
|
|
272
|
+
if (parts.refreshToken) {
|
|
273
|
+
const now = nowMs();
|
|
274
|
+
this.accounts = [
|
|
275
|
+
{
|
|
276
|
+
index: 0,
|
|
277
|
+
email: undefined,
|
|
278
|
+
addedAt: now,
|
|
279
|
+
lastUsed: 0,
|
|
280
|
+
parts,
|
|
281
|
+
access: authFallback.access,
|
|
282
|
+
expires: authFallback.expires,
|
|
283
|
+
enabled: true,
|
|
284
|
+
rateLimitResetTimes: {},
|
|
285
|
+
touchedForQuota: {},
|
|
286
|
+
},
|
|
287
|
+
];
|
|
288
|
+
this.cursor = 0;
|
|
289
|
+
this.currentAccountIndexByFamily.kimi = 0;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
getAccountCount() {
|
|
294
|
+
return this.getEnabledAccounts().length;
|
|
295
|
+
}
|
|
296
|
+
getTotalAccountCount() {
|
|
297
|
+
return this.accounts.length;
|
|
298
|
+
}
|
|
299
|
+
getEnabledAccounts() {
|
|
300
|
+
return this.accounts.filter((account) => account.enabled !== false);
|
|
301
|
+
}
|
|
302
|
+
getAccountsSnapshot() {
|
|
303
|
+
return this.accounts.map((a) => ({ ...a, parts: { ...a.parts }, rateLimitResetTimes: { ...a.rateLimitResetTimes } }));
|
|
304
|
+
}
|
|
305
|
+
getCurrentAccountForFamily(family) {
|
|
306
|
+
const currentIndex = this.currentAccountIndexByFamily[family];
|
|
307
|
+
if (currentIndex >= 0 && currentIndex < this.accounts.length) {
|
|
308
|
+
const account = this.accounts[currentIndex] ?? null;
|
|
309
|
+
// Only return account if it's enabled - disabled accounts should not be selected
|
|
310
|
+
if (account && account.enabled !== false) {
|
|
311
|
+
return account;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
markSwitched(account, reason, family) {
|
|
317
|
+
account.lastSwitchReason = reason;
|
|
318
|
+
this.currentAccountIndexByFamily[family] = account.index;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Check if we should show an account switch toast.
|
|
322
|
+
* Debounces repeated toasts for the same account.
|
|
323
|
+
*/
|
|
324
|
+
shouldShowAccountToast(accountIndex, debounceMs = 30000) {
|
|
325
|
+
const now = nowMs();
|
|
326
|
+
if (accountIndex !== this.lastToastAccountIndex) {
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
return now - this.lastToastTime >= debounceMs;
|
|
330
|
+
}
|
|
331
|
+
markToastShown(accountIndex) {
|
|
332
|
+
this.lastToastAccountIndex = accountIndex;
|
|
333
|
+
this.lastToastTime = nowMs();
|
|
334
|
+
}
|
|
335
|
+
getCurrentOrNextForFamily(family, model, strategy = 'sticky', headerStyle = 'kimi-cli', pidOffsetEnabled = false, softQuotaThresholdPercent = 100, softQuotaCacheTtlMs = 10 * 60 * 1000) {
|
|
336
|
+
const quotaKey = getQuotaKey(family, headerStyle, model);
|
|
337
|
+
if (strategy === 'round-robin') {
|
|
338
|
+
const next = this.getNextForFamily(family, model, headerStyle, softQuotaThresholdPercent, softQuotaCacheTtlMs);
|
|
339
|
+
if (next) {
|
|
340
|
+
this.markTouchedForQuota(next, quotaKey);
|
|
341
|
+
this.currentAccountIndexByFamily[family] = next.index;
|
|
342
|
+
}
|
|
343
|
+
return next;
|
|
344
|
+
}
|
|
345
|
+
if (strategy === 'hybrid') {
|
|
346
|
+
const healthTracker = getHealthTracker();
|
|
347
|
+
const tokenTracker = getTokenTracker();
|
|
348
|
+
const accountsWithMetrics = this.accounts
|
|
349
|
+
.filter(acc => acc.enabled !== false)
|
|
350
|
+
.map(acc => {
|
|
351
|
+
clearExpiredRateLimits(acc);
|
|
352
|
+
return {
|
|
353
|
+
index: acc.index,
|
|
354
|
+
lastUsed: acc.lastUsed,
|
|
355
|
+
healthScore: healthTracker.getScore(acc.index),
|
|
356
|
+
isRateLimited: isRateLimitedForFamily(acc, family, model) ||
|
|
357
|
+
isOverSoftQuotaThreshold(acc, family, softQuotaThresholdPercent, softQuotaCacheTtlMs, model),
|
|
358
|
+
isCoolingDown: this.isAccountCoolingDown(acc),
|
|
359
|
+
};
|
|
360
|
+
});
|
|
361
|
+
// Get current account index for stickiness
|
|
362
|
+
const currentIndex = this.currentAccountIndexByFamily[family] ?? null;
|
|
363
|
+
const selectedIndex = selectHybridAccount(accountsWithMetrics, tokenTracker, currentIndex);
|
|
364
|
+
if (selectedIndex !== null) {
|
|
365
|
+
const selected = this.accounts[selectedIndex];
|
|
366
|
+
if (selected) {
|
|
367
|
+
selected.lastUsed = nowMs();
|
|
368
|
+
this.markTouchedForQuota(selected, quotaKey);
|
|
369
|
+
this.currentAccountIndexByFamily[family] = selected.index;
|
|
370
|
+
return selected;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// Fallback: sticky selection (used when hybrid finds no candidates)
|
|
375
|
+
// PID-based offset for multi-session distribution (opt-in)
|
|
376
|
+
// Different sessions (PIDs) will prefer different starting accounts
|
|
377
|
+
if (pidOffsetEnabled && !this.sessionOffsetApplied[family] && this.accounts.length > 1) {
|
|
378
|
+
const pidOffset = process.pid % this.accounts.length;
|
|
379
|
+
const baseIndex = this.currentAccountIndexByFamily[family] ?? 0;
|
|
380
|
+
const newIndex = (baseIndex + pidOffset) % this.accounts.length;
|
|
381
|
+
debugLogToFile(`[Account] Applying PID offset: pid=${process.pid} offset=${pidOffset} family=${family} index=${baseIndex}->${newIndex}`);
|
|
382
|
+
this.currentAccountIndexByFamily[family] = newIndex;
|
|
383
|
+
this.sessionOffsetApplied[family] = true;
|
|
384
|
+
}
|
|
385
|
+
const current = this.getCurrentAccountForFamily(family);
|
|
386
|
+
if (current) {
|
|
387
|
+
clearExpiredRateLimits(current);
|
|
388
|
+
const isLimitedForRequestedStyle = isRateLimitedForHeaderStyle(current, family, headerStyle, model);
|
|
389
|
+
const isOverThreshold = isOverSoftQuotaThreshold(current, family, softQuotaThresholdPercent, softQuotaCacheTtlMs, model);
|
|
390
|
+
if (!isLimitedForRequestedStyle && !isOverThreshold && !this.isAccountCoolingDown(current)) {
|
|
391
|
+
this.markTouchedForQuota(current, quotaKey);
|
|
392
|
+
return current;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const next = this.getNextForFamily(family, model, headerStyle, softQuotaThresholdPercent, softQuotaCacheTtlMs);
|
|
396
|
+
if (next) {
|
|
397
|
+
this.markTouchedForQuota(next, quotaKey);
|
|
398
|
+
this.currentAccountIndexByFamily[family] = next.index;
|
|
399
|
+
}
|
|
400
|
+
return next;
|
|
401
|
+
}
|
|
402
|
+
getNextForFamily(family, model, headerStyle = "kimi-cli", softQuotaThresholdPercent = 100, softQuotaCacheTtlMs = 10 * 60 * 1000) {
|
|
403
|
+
const available = this.accounts.filter((a) => {
|
|
404
|
+
clearExpiredRateLimits(a);
|
|
405
|
+
return a.enabled !== false &&
|
|
406
|
+
!isRateLimitedForHeaderStyle(a, family, headerStyle, model) &&
|
|
407
|
+
!isOverSoftQuotaThreshold(a, family, softQuotaThresholdPercent, softQuotaCacheTtlMs, model) &&
|
|
408
|
+
!this.isAccountCoolingDown(a);
|
|
409
|
+
});
|
|
410
|
+
if (available.length === 0) {
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
const account = available[this.cursor % available.length];
|
|
414
|
+
if (!account) {
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
this.cursor++;
|
|
418
|
+
// Note: lastUsed is now updated after successful request via markAccountUsed()
|
|
419
|
+
return account;
|
|
420
|
+
}
|
|
421
|
+
markRateLimited(account, retryAfterMs, family, headerStyle = "kimi-cli", model) {
|
|
422
|
+
const key = getQuotaKey(family, headerStyle, model);
|
|
423
|
+
account.rateLimitResetTimes[key] = nowMs() + retryAfterMs;
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Mark an account as used after a successful API request.
|
|
427
|
+
* This updates the lastUsed timestamp for freshness calculations.
|
|
428
|
+
* Should be called AFTER request completion, not during account selection.
|
|
429
|
+
*/
|
|
430
|
+
markAccountUsed(accountIndex) {
|
|
431
|
+
const account = this.accounts.find(a => a.index === accountIndex);
|
|
432
|
+
if (account) {
|
|
433
|
+
account.lastUsed = nowMs();
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
markRateLimitedWithReason(account, family, headerStyle, model, reason, retryAfterMs, failureTtlMs = 3600_000) {
|
|
437
|
+
const now = nowMs();
|
|
438
|
+
// TTL-based reset: if last failure was more than failureTtlMs ago, reset count
|
|
439
|
+
if (account.lastFailureTime !== undefined && (now - account.lastFailureTime) > failureTtlMs) {
|
|
440
|
+
account.consecutiveFailures = 0;
|
|
441
|
+
}
|
|
442
|
+
const failures = (account.consecutiveFailures ?? 0) + 1;
|
|
443
|
+
account.consecutiveFailures = failures;
|
|
444
|
+
account.lastFailureTime = now;
|
|
445
|
+
const backoffMs = calculateBackoffMs(reason, failures - 1, retryAfterMs);
|
|
446
|
+
const key = getQuotaKey(family, headerStyle, model);
|
|
447
|
+
account.rateLimitResetTimes[key] = now + backoffMs;
|
|
448
|
+
return backoffMs;
|
|
449
|
+
}
|
|
450
|
+
markRequestSuccess(account) {
|
|
451
|
+
if (account.consecutiveFailures) {
|
|
452
|
+
account.consecutiveFailures = 0;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
clearAllRateLimitsForFamily(family, model) {
|
|
456
|
+
for (const account of this.accounts) {
|
|
457
|
+
const key = getQuotaKey(family, "kimi-cli", model);
|
|
458
|
+
delete account.rateLimitResetTimes[key];
|
|
459
|
+
account.consecutiveFailures = 0;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
shouldTryOptimisticReset(family, model) {
|
|
463
|
+
const minWaitMs = this.getMinWaitTimeForFamily(family, model);
|
|
464
|
+
return minWaitMs > 0 && minWaitMs <= 2_000;
|
|
465
|
+
}
|
|
466
|
+
markAccountCoolingDown(account, cooldownMs, reason) {
|
|
467
|
+
account.coolingDownUntil = nowMs() + cooldownMs;
|
|
468
|
+
account.cooldownReason = reason;
|
|
469
|
+
}
|
|
470
|
+
isAccountCoolingDown(account) {
|
|
471
|
+
if (account.coolingDownUntil === undefined) {
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
if (nowMs() >= account.coolingDownUntil) {
|
|
475
|
+
this.clearAccountCooldown(account);
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
clearAccountCooldown(account) {
|
|
481
|
+
delete account.coolingDownUntil;
|
|
482
|
+
delete account.cooldownReason;
|
|
483
|
+
}
|
|
484
|
+
getAccountCooldownReason(account) {
|
|
485
|
+
return this.isAccountCoolingDown(account) ? account.cooldownReason : undefined;
|
|
486
|
+
}
|
|
487
|
+
markTouchedForQuota(account, quotaKey) {
|
|
488
|
+
account.touchedForQuota[quotaKey] = nowMs();
|
|
489
|
+
}
|
|
490
|
+
isFreshForQuota(account, quotaKey) {
|
|
491
|
+
const touchedAt = account.touchedForQuota[quotaKey];
|
|
492
|
+
if (!touchedAt)
|
|
493
|
+
return true;
|
|
494
|
+
const resetTime = account.rateLimitResetTimes[quotaKey];
|
|
495
|
+
if (resetTime && touchedAt < resetTime)
|
|
496
|
+
return true;
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
getFreshAccountsForQuota(quotaKey, family, model) {
|
|
500
|
+
return this.accounts.filter(acc => {
|
|
501
|
+
clearExpiredRateLimits(acc);
|
|
502
|
+
return acc.enabled !== false &&
|
|
503
|
+
this.isFreshForQuota(acc, quotaKey) &&
|
|
504
|
+
!isRateLimitedForFamily(acc, family, model) &&
|
|
505
|
+
!this.isAccountCoolingDown(acc);
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
isRateLimitedForHeaderStyle(account, family, headerStyle, model) {
|
|
509
|
+
return isRateLimitedForHeaderStyle(account, family, headerStyle, model);
|
|
510
|
+
}
|
|
511
|
+
getAvailableHeaderStyle(account, family, model) {
|
|
512
|
+
clearExpiredRateLimits(account);
|
|
513
|
+
if (!isRateLimitedForFamily(account, family, model)) {
|
|
514
|
+
return "kimi-cli";
|
|
515
|
+
}
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Check if any OTHER account has quota available for the given family/model.
|
|
520
|
+
*
|
|
521
|
+
* @param currentAccountIndex - Index of the current account (will be excluded from check)
|
|
522
|
+
* @param family - Model family ("kimi")
|
|
523
|
+
* @param model - Optional model name for model-specific rate limits
|
|
524
|
+
* @returns true if any other enabled, non-cooling-down account has quota available
|
|
525
|
+
*/
|
|
526
|
+
hasOtherAccountWithQuotaAvailable(currentAccountIndex, family, model) {
|
|
527
|
+
return this.accounts.some(acc => {
|
|
528
|
+
if (acc.index === currentAccountIndex)
|
|
529
|
+
return false;
|
|
530
|
+
if (acc.enabled === false)
|
|
531
|
+
return false;
|
|
532
|
+
if (this.isAccountCoolingDown(acc))
|
|
533
|
+
return false;
|
|
534
|
+
clearExpiredRateLimits(acc);
|
|
535
|
+
return !isRateLimitedForFamily(acc, family, model);
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
setAccountEnabled(accountIndex, enabled) {
|
|
539
|
+
const account = this.accounts[accountIndex];
|
|
540
|
+
if (!account) {
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
account.enabled = enabled;
|
|
544
|
+
if (!enabled) {
|
|
545
|
+
for (const family of Object.keys(this.currentAccountIndexByFamily)) {
|
|
546
|
+
if (this.currentAccountIndexByFamily[family] === accountIndex) {
|
|
547
|
+
const next = this.accounts.find((a, i) => i !== accountIndex && a.enabled !== false);
|
|
548
|
+
this.currentAccountIndexByFamily[family] = next?.index ?? -1;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
this.requestSaveToDisk();
|
|
553
|
+
return true;
|
|
554
|
+
}
|
|
555
|
+
removeAccountByIndex(accountIndex) {
|
|
556
|
+
if (accountIndex < 0 || accountIndex >= this.accounts.length) {
|
|
557
|
+
return false;
|
|
558
|
+
}
|
|
559
|
+
const account = this.accounts[accountIndex];
|
|
560
|
+
if (!account) {
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
return this.removeAccount(account);
|
|
564
|
+
}
|
|
565
|
+
removeAccount(account) {
|
|
566
|
+
const idx = this.accounts.indexOf(account);
|
|
567
|
+
if (idx < 0) {
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
570
|
+
this.accounts.splice(idx, 1);
|
|
571
|
+
this.accounts.forEach((acc, index) => {
|
|
572
|
+
acc.index = index;
|
|
573
|
+
});
|
|
574
|
+
if (this.accounts.length === 0) {
|
|
575
|
+
this.cursor = 0;
|
|
576
|
+
this.currentAccountIndexByFamily.kimi = -1;
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
if (this.cursor > idx) {
|
|
580
|
+
this.cursor -= 1;
|
|
581
|
+
}
|
|
582
|
+
this.cursor = this.cursor % this.accounts.length;
|
|
583
|
+
for (const family of ["kimi"]) {
|
|
584
|
+
if (this.currentAccountIndexByFamily[family] > idx) {
|
|
585
|
+
this.currentAccountIndexByFamily[family] -= 1;
|
|
586
|
+
}
|
|
587
|
+
if (this.currentAccountIndexByFamily[family] >= this.accounts.length) {
|
|
588
|
+
this.currentAccountIndexByFamily[family] = -1;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return true;
|
|
592
|
+
}
|
|
593
|
+
updateFromAuth(account, auth) {
|
|
594
|
+
account.parts = { refreshToken: auth.refresh };
|
|
595
|
+
account.access = auth.access;
|
|
596
|
+
account.expires = auth.expires;
|
|
597
|
+
}
|
|
598
|
+
toAuthDetails(account) {
|
|
599
|
+
return {
|
|
600
|
+
type: "oauth",
|
|
601
|
+
refresh: account.parts.refreshToken,
|
|
602
|
+
access: account.access,
|
|
603
|
+
expires: account.expires,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
getMinWaitTimeForFamily(family, model, _headerStyle, _strict) {
|
|
607
|
+
const available = this.accounts.filter((a) => {
|
|
608
|
+
clearExpiredRateLimits(a);
|
|
609
|
+
return a.enabled !== false && !isRateLimitedForFamily(a, family, model);
|
|
610
|
+
});
|
|
611
|
+
if (available.length > 0) {
|
|
612
|
+
return 0;
|
|
613
|
+
}
|
|
614
|
+
const waitTimes = [];
|
|
615
|
+
for (const a of this.accounts) {
|
|
616
|
+
const key = getQuotaKey(family, "kimi-cli", model);
|
|
617
|
+
const t = a.rateLimitResetTimes[key];
|
|
618
|
+
if (t !== undefined)
|
|
619
|
+
waitTimes.push(Math.max(0, t - nowMs()));
|
|
620
|
+
}
|
|
621
|
+
return waitTimes.length > 0 ? Math.min(...waitTimes) : 0;
|
|
622
|
+
}
|
|
623
|
+
getAccounts() {
|
|
624
|
+
return [...this.accounts];
|
|
625
|
+
}
|
|
626
|
+
async saveToDisk() {
|
|
627
|
+
const kimiIndex = Math.max(0, this.currentAccountIndexByFamily.kimi);
|
|
628
|
+
const storage = {
|
|
629
|
+
version: 1,
|
|
630
|
+
accounts: this.accounts.map((a) => ({
|
|
631
|
+
email: a.email,
|
|
632
|
+
refreshToken: a.parts.refreshToken,
|
|
633
|
+
addedAt: a.addedAt,
|
|
634
|
+
lastUsed: a.lastUsed,
|
|
635
|
+
enabled: a.enabled,
|
|
636
|
+
lastSwitchReason: a.lastSwitchReason,
|
|
637
|
+
rateLimitResetTimes: Object.keys(a.rateLimitResetTimes).length > 0 ? a.rateLimitResetTimes : undefined,
|
|
638
|
+
coolingDownUntil: a.coolingDownUntil,
|
|
639
|
+
cooldownReason: a.cooldownReason,
|
|
640
|
+
fingerprint: a.fingerprint,
|
|
641
|
+
fingerprintHistory: a.fingerprintHistory?.length ? a.fingerprintHistory : undefined,
|
|
642
|
+
cachedQuota: a.cachedQuota && Object.keys(a.cachedQuota).length > 0 ? a.cachedQuota : undefined,
|
|
643
|
+
cachedQuotaUpdatedAt: a.cachedQuotaUpdatedAt,
|
|
644
|
+
})),
|
|
645
|
+
activeIndex: kimiIndex,
|
|
646
|
+
activeIndexByFamily: {
|
|
647
|
+
kimi: kimiIndex,
|
|
648
|
+
},
|
|
649
|
+
};
|
|
650
|
+
await saveAccounts(storage);
|
|
651
|
+
}
|
|
652
|
+
requestSaveToDisk() {
|
|
653
|
+
if (this.savePending) {
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
this.savePending = true;
|
|
657
|
+
this.saveTimeout = setTimeout(() => {
|
|
658
|
+
void this.executeSave();
|
|
659
|
+
}, 1000);
|
|
660
|
+
}
|
|
661
|
+
async flushSaveToDisk() {
|
|
662
|
+
if (!this.savePending) {
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
return new Promise((resolve) => {
|
|
666
|
+
this.savePromiseResolvers.push(resolve);
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
async executeSave() {
|
|
670
|
+
this.savePending = false;
|
|
671
|
+
this.saveTimeout = null;
|
|
672
|
+
try {
|
|
673
|
+
await this.saveToDisk();
|
|
674
|
+
}
|
|
675
|
+
catch {
|
|
676
|
+
// best-effort persistence; avoid unhandled rejection from timer-driven saves
|
|
677
|
+
}
|
|
678
|
+
finally {
|
|
679
|
+
const resolvers = this.savePromiseResolvers;
|
|
680
|
+
this.savePromiseResolvers = [];
|
|
681
|
+
for (const resolve of resolvers) {
|
|
682
|
+
resolve();
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
// ========== Fingerprint Management ==========
|
|
687
|
+
/**
|
|
688
|
+
* Regenerate fingerprint for an account, saving the old one to history.
|
|
689
|
+
* @param accountIndex - Index of the account to regenerate fingerprint for
|
|
690
|
+
* @returns The new fingerprint, or null if account not found
|
|
691
|
+
*/
|
|
692
|
+
regenerateAccountFingerprint(accountIndex) {
|
|
693
|
+
const account = this.accounts[accountIndex];
|
|
694
|
+
if (!account)
|
|
695
|
+
return null;
|
|
696
|
+
// Save current fingerprint to history if it exists
|
|
697
|
+
if (account.fingerprint) {
|
|
698
|
+
const historyEntry = {
|
|
699
|
+
fingerprint: account.fingerprint,
|
|
700
|
+
timestamp: nowMs(),
|
|
701
|
+
reason: 'regenerated',
|
|
702
|
+
};
|
|
703
|
+
if (!account.fingerprintHistory) {
|
|
704
|
+
account.fingerprintHistory = [];
|
|
705
|
+
}
|
|
706
|
+
// Add to beginning of history (most recent first)
|
|
707
|
+
account.fingerprintHistory.unshift(historyEntry);
|
|
708
|
+
// Trim to max history size
|
|
709
|
+
if (account.fingerprintHistory.length > MAX_FINGERPRINT_HISTORY) {
|
|
710
|
+
account.fingerprintHistory = account.fingerprintHistory.slice(0, MAX_FINGERPRINT_HISTORY);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
// Generate and assign new fingerprint
|
|
714
|
+
account.fingerprint = generateFingerprint();
|
|
715
|
+
this.requestSaveToDisk();
|
|
716
|
+
return account.fingerprint;
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Restore a fingerprint from history for an account.
|
|
720
|
+
* @param accountIndex - Index of the account
|
|
721
|
+
* @param historyIndex - Index in the fingerprint history to restore from (0 = most recent)
|
|
722
|
+
* @returns The restored fingerprint, or null if account/history not found
|
|
723
|
+
*/
|
|
724
|
+
restoreAccountFingerprint(accountIndex, historyIndex) {
|
|
725
|
+
const account = this.accounts[accountIndex];
|
|
726
|
+
if (!account)
|
|
727
|
+
return null;
|
|
728
|
+
const history = account.fingerprintHistory;
|
|
729
|
+
if (!history || historyIndex < 0 || historyIndex >= history.length) {
|
|
730
|
+
return null;
|
|
731
|
+
}
|
|
732
|
+
// Capture the fingerprint to restore BEFORE modifying history
|
|
733
|
+
const fingerprintToRestore = history[historyIndex].fingerprint;
|
|
734
|
+
// Save current fingerprint to history before restoring (if it exists)
|
|
735
|
+
if (account.fingerprint) {
|
|
736
|
+
const historyEntry = {
|
|
737
|
+
fingerprint: account.fingerprint,
|
|
738
|
+
timestamp: nowMs(),
|
|
739
|
+
reason: 'restored',
|
|
740
|
+
};
|
|
741
|
+
account.fingerprintHistory.unshift(historyEntry);
|
|
742
|
+
// Trim to max history size
|
|
743
|
+
if (account.fingerprintHistory.length > MAX_FINGERPRINT_HISTORY) {
|
|
744
|
+
account.fingerprintHistory = account.fingerprintHistory.slice(0, MAX_FINGERPRINT_HISTORY);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
// Restore the fingerprint
|
|
748
|
+
account.fingerprint = { ...fingerprintToRestore, createdAt: nowMs() };
|
|
749
|
+
this.requestSaveToDisk();
|
|
750
|
+
return account.fingerprint;
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Get fingerprint history for an account.
|
|
754
|
+
* @param accountIndex - Index of the account
|
|
755
|
+
* @returns Array of fingerprint versions, or empty array if not found
|
|
756
|
+
*/
|
|
757
|
+
getAccountFingerprintHistory(accountIndex) {
|
|
758
|
+
const account = this.accounts[accountIndex];
|
|
759
|
+
if (!account || !account.fingerprintHistory) {
|
|
760
|
+
return [];
|
|
761
|
+
}
|
|
762
|
+
return [...account.fingerprintHistory];
|
|
763
|
+
}
|
|
764
|
+
updateQuotaCache(accountIndex, quotaGroups) {
|
|
765
|
+
const account = this.accounts[accountIndex];
|
|
766
|
+
if (account) {
|
|
767
|
+
account.cachedQuota = quotaGroups;
|
|
768
|
+
account.cachedQuotaUpdatedAt = nowMs();
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
isAccountOverSoftQuota(account, family, thresholdPercent, cacheTtlMs, model) {
|
|
772
|
+
return isOverSoftQuotaThreshold(account, family, thresholdPercent, cacheTtlMs, model);
|
|
773
|
+
}
|
|
774
|
+
getAccountsForQuotaCheck() {
|
|
775
|
+
return this.accounts.map((a) => ({
|
|
776
|
+
email: a.email,
|
|
777
|
+
refreshToken: a.parts.refreshToken,
|
|
778
|
+
addedAt: a.addedAt,
|
|
779
|
+
lastUsed: a.lastUsed,
|
|
780
|
+
enabled: a.enabled,
|
|
781
|
+
}));
|
|
782
|
+
}
|
|
783
|
+
getOldestQuotaCacheAge() {
|
|
784
|
+
let oldest = null;
|
|
785
|
+
for (const acc of this.accounts) {
|
|
786
|
+
if (acc.enabled === false)
|
|
787
|
+
continue;
|
|
788
|
+
if (acc.cachedQuotaUpdatedAt == null)
|
|
789
|
+
return null;
|
|
790
|
+
const age = nowMs() - acc.cachedQuotaUpdatedAt;
|
|
791
|
+
if (oldest === null || age > oldest)
|
|
792
|
+
oldest = age;
|
|
793
|
+
}
|
|
794
|
+
return oldest;
|
|
795
|
+
}
|
|
796
|
+
areAllAccountsOverSoftQuota(family, thresholdPercent, cacheTtlMs, model) {
|
|
797
|
+
if (thresholdPercent >= 100)
|
|
798
|
+
return false;
|
|
799
|
+
const enabled = this.accounts.filter(a => a.enabled !== false);
|
|
800
|
+
if (enabled.length === 0)
|
|
801
|
+
return false;
|
|
802
|
+
return enabled.every(a => isOverSoftQuotaThreshold(a, family, thresholdPercent, cacheTtlMs, model));
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Get minimum wait time until any account's soft quota resets.
|
|
806
|
+
* Returns 0 if any account is available (not over threshold).
|
|
807
|
+
* Returns the minimum resetTime across all over-threshold accounts.
|
|
808
|
+
* Returns null if no resetTime data is available.
|
|
809
|
+
*/
|
|
810
|
+
getMinWaitTimeForSoftQuota(family, thresholdPercent, cacheTtlMs, model) {
|
|
811
|
+
if (thresholdPercent >= 100)
|
|
812
|
+
return 0;
|
|
813
|
+
const enabled = this.accounts.filter(a => a.enabled !== false);
|
|
814
|
+
if (enabled.length === 0)
|
|
815
|
+
return null;
|
|
816
|
+
// If any account is available (not over threshold), no wait needed
|
|
817
|
+
const available = enabled.filter(a => !isOverSoftQuotaThreshold(a, family, thresholdPercent, cacheTtlMs, model));
|
|
818
|
+
if (available.length > 0)
|
|
819
|
+
return 0;
|
|
820
|
+
// All accounts are over threshold - find earliest reset time
|
|
821
|
+
// Fail-open (return null = no wait info) if model is missing to avoid blocking on wrong quota.
|
|
822
|
+
if (!model)
|
|
823
|
+
return null;
|
|
824
|
+
const quotaGroup = resolveQuotaGroup(family, model);
|
|
825
|
+
const now = nowMs();
|
|
826
|
+
const waitTimes = [];
|
|
827
|
+
for (const acc of enabled) {
|
|
828
|
+
const groupData = acc.cachedQuota?.[quotaGroup];
|
|
829
|
+
if (groupData?.resetTime) {
|
|
830
|
+
const resetTimestamp = Date.parse(groupData.resetTime);
|
|
831
|
+
if (Number.isFinite(resetTimestamp)) {
|
|
832
|
+
waitTimes.push(Math.max(0, resetTimestamp - now));
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
if (waitTimes.length === 0)
|
|
837
|
+
return null;
|
|
838
|
+
const minWait = Math.min(...waitTimes);
|
|
839
|
+
// Treat 0 as stale cache (resetTime in the past) → fail-open to avoid spin loop
|
|
840
|
+
return minWait === 0 ? null : minWait;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
//# sourceMappingURL=accounts.js.map
|