@goodandready/dsh-clinebot 0.3.7 → 0.3.9
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/CHANGELOG.md +24 -0
- package/README.md +15 -2
- package/README.ru.md +199 -0
- package/README.zh.md +201 -0
- package/docs/README.ru.md +14 -1
- package/docs/README.zh.md +14 -1
- package/docs/design/DESIGN.md +15 -1
- package/lib/client.js +122 -106
- package/lib/cline-client.js +340 -131
- package/lib/index.js +143 -109
- package/lib/models.js +69 -0
- package/lib/updater.js +245 -0
- package/package.json +1 -1
package/lib/cline-client.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* ClineBot / ClinePass client helpers for DeepSeek Harness.
|
|
3
3
|
*
|
|
4
4
|
* Implements OpenAI-compatible chat completions interface, usage quota tracking,
|
|
5
|
-
* and secure credential storage
|
|
5
|
+
* Stale-While-Revalidate caching, smart failover, and secure credential storage.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import {
|
|
@@ -19,6 +19,7 @@ export const DEFAULT_BASE_URL = 'https://api.cline.bot/api/v1'
|
|
|
19
19
|
export const DEFAULT_API_KEY_ENV = 'CLINEBOT_API_KEY'
|
|
20
20
|
export const DEFAULT_TIMEOUT_MS = 15000
|
|
21
21
|
export const DEFAULT_SMOKE_TIMEOUT_MS = 25000
|
|
22
|
+
export const USAGE_CACHE_TTL_MS = 60000
|
|
22
23
|
|
|
23
24
|
export { PROVIDER_ID, PROVIDER_DISPLAY_NAME, DEFAULT_MODEL_ID }
|
|
24
25
|
|
|
@@ -130,8 +131,14 @@ function abortAfter(ms) {
|
|
|
130
131
|
return { signal: ac.signal, cancel: () => clearTimeout(timer) }
|
|
131
132
|
}
|
|
132
133
|
|
|
133
|
-
// In-memory
|
|
134
|
-
const usageCache = new Map()
|
|
134
|
+
// In-memory caches for quota queries and host health probes
|
|
135
|
+
export const usageCache = new Map()
|
|
136
|
+
export const probeCache = new Map()
|
|
137
|
+
|
|
138
|
+
export function clearProbeCache() {
|
|
139
|
+
probeCache.clear()
|
|
140
|
+
}
|
|
141
|
+
|
|
135
142
|
export function clearUsageCache() {
|
|
136
143
|
usageCache.clear()
|
|
137
144
|
}
|
|
@@ -149,7 +156,6 @@ export async function retryWithBackoff(fn, {
|
|
|
149
156
|
while (true) {
|
|
150
157
|
try {
|
|
151
158
|
const res = await fn()
|
|
152
|
-
// If HTTP response-like object with 5xx or 429 status and can retry
|
|
153
159
|
if (res && typeof res.status === 'number' && [429, 502, 503, 504].includes(res.status) && attempt < maxRetries) {
|
|
154
160
|
attempt++
|
|
155
161
|
const retryAfter = res.headers?.get ? Number(res.headers.get('retry-after')) * 1000 : 0
|
|
@@ -173,14 +179,13 @@ export async function retryWithBackoff(fn, {
|
|
|
173
179
|
|
|
174
180
|
/**
|
|
175
181
|
* Fetch official ClinePass rate limits and account quota.
|
|
176
|
-
*
|
|
177
|
-
* - GET /users/me/plan/usage-limits (5-hour, weekly, monthly rolling limits)
|
|
178
|
-
* - GET /users/me (account metadata)
|
|
182
|
+
* Uses Stale-While-Revalidate (SWR) caching with keep-alive and network retry.
|
|
179
183
|
*/
|
|
180
184
|
export async function fetchUsageLimits(baseUrl, apiKey, {
|
|
181
185
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
182
186
|
fetchImpl = fetch,
|
|
183
187
|
bypassCache = false,
|
|
188
|
+
ttlMs = USAGE_CACHE_TTL_MS,
|
|
184
189
|
} = {}) {
|
|
185
190
|
const base = normalizeBaseUrl(baseUrl)
|
|
186
191
|
if (!apiKey) {
|
|
@@ -190,153 +195,202 @@ export async function fetchUsageLimits(baseUrl, apiKey, {
|
|
|
190
195
|
const cacheKey = `cline:usage:${apiKey.slice(-8)}`
|
|
191
196
|
const now = Date.now()
|
|
192
197
|
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const { signal, cancel } = abortAfter(timeoutMs)
|
|
201
|
-
try {
|
|
202
|
-
const headers = {
|
|
203
|
-
Authorization: `Bearer ${apiKey}`,
|
|
204
|
-
Accept: 'application/json',
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// 1. Fetch usage limits
|
|
208
|
-
const limitsRes = await fetchImpl(`${base}/users/me/plan/usage-limits`, {
|
|
209
|
-
method: 'GET',
|
|
210
|
-
headers,
|
|
211
|
-
signal,
|
|
212
|
-
})
|
|
213
|
-
|
|
214
|
-
if (!limitsRes.ok) {
|
|
215
|
-
const errText = await limitsRes.text().catch(() => '')
|
|
216
|
-
return {
|
|
217
|
-
ok: false,
|
|
218
|
-
status: limitsRes.status,
|
|
219
|
-
error: `ClinePass limits error (HTTP ${limitsRes.status}): ${errText.slice(0, 150)}`,
|
|
198
|
+
const doFetch = async () => {
|
|
199
|
+
const { signal, cancel } = abortAfter(timeoutMs)
|
|
200
|
+
try {
|
|
201
|
+
const headers = {
|
|
202
|
+
Authorization: `Bearer ${apiKey}`,
|
|
203
|
+
Accept: 'application/json',
|
|
220
204
|
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
const limitsData = await limitsRes.json().catch(() => ({}))
|
|
224
|
-
const rawLimits = limitsData?.data?.limits || limitsData?.limits || []
|
|
225
205
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
206
|
+
let limitsRes
|
|
207
|
+
try {
|
|
208
|
+
limitsRes = await fetchImpl(`${base}/users/me/plan/usage-limits`, {
|
|
209
|
+
method: 'GET',
|
|
210
|
+
headers,
|
|
211
|
+
signal,
|
|
212
|
+
keepalive: true,
|
|
213
|
+
})
|
|
214
|
+
} catch (netErr) {
|
|
215
|
+
// Quick 1-retry fallback on transient network drop
|
|
216
|
+
await new Promise((r) => setTimeout(r, 250))
|
|
217
|
+
limitsRes = await fetchImpl(`${base}/users/me/plan/usage-limits`, {
|
|
218
|
+
method: 'GET',
|
|
219
|
+
headers,
|
|
220
|
+
signal,
|
|
221
|
+
keepalive: true,
|
|
222
|
+
})
|
|
238
223
|
}
|
|
239
|
-
}
|
|
240
224
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
225
|
+
if (!limitsRes.ok) {
|
|
226
|
+
const errText = await limitsRes.text().catch(() => '')
|
|
227
|
+
const outcome = {
|
|
228
|
+
ok: false,
|
|
229
|
+
status: limitsRes.status,
|
|
230
|
+
error: `ClinePass limits error (HTTP ${limitsRes.status}): ${errText.slice(0, 150)}`,
|
|
231
|
+
checkedAt: Date.now(),
|
|
232
|
+
}
|
|
233
|
+
if (!usageCache.has(cacheKey)) {
|
|
234
|
+
usageCache.set(cacheKey, { data: outcome, expiresAt: Date.now() + 5000, isRevalidating: false })
|
|
235
|
+
}
|
|
236
|
+
return outcome
|
|
237
|
+
}
|
|
250
238
|
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
239
|
+
const limitsData = await limitsRes.json().catch(() => ({}))
|
|
240
|
+
const rawLimits = limitsData?.data?.limits || limitsData?.limits || []
|
|
241
|
+
|
|
242
|
+
const parseWindow = (type) => {
|
|
243
|
+
const found = Array.isArray(rawLimits) ? rawLimits.find((l) => l.type === type) : null
|
|
244
|
+
if (!found) return null
|
|
245
|
+
const percentUsed = typeof found.percentUsed === 'number'
|
|
246
|
+
? Math.max(0, Math.min(100, Math.round(found.percentUsed * 10) / 10))
|
|
247
|
+
: 0
|
|
248
|
+
const remainingPercent = Math.max(0, Math.round((100 - percentUsed) * 10) / 10)
|
|
249
|
+
return {
|
|
250
|
+
type,
|
|
251
|
+
percentUsed,
|
|
252
|
+
remainingPercent,
|
|
253
|
+
resetsAt: found.resetsAt || null,
|
|
254
|
+
}
|
|
262
255
|
}
|
|
263
256
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
257
|
+
const fiveHour = parseWindow('5-hour')
|
|
258
|
+
const weekly = parseWindow('weekly')
|
|
259
|
+
const monthly = parseWindow('monthly')
|
|
260
|
+
|
|
261
|
+
let userEmail = null
|
|
262
|
+
let createdAt = null
|
|
263
|
+
let planDisplayName = 'ClinePass ($9.99/mo)'
|
|
264
|
+
let dynamicModels = []
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
const [meRes, planRes] = await Promise.all([
|
|
268
|
+
fetchImpl(`${base}/users/me`, { method: 'GET', headers, signal, keepalive: true }).catch(() => null),
|
|
269
|
+
fetchImpl(`${base}/users/me/plan`, { method: 'GET', headers, signal, keepalive: true }).catch(() => null),
|
|
270
|
+
])
|
|
271
|
+
|
|
272
|
+
if (meRes?.ok) {
|
|
273
|
+
const meData = await meRes.json().catch(() => ({}))
|
|
274
|
+
userEmail = meData?.data?.email || meData?.email || null
|
|
275
|
+
createdAt = meData?.data?.createdAt || meData?.createdAt || null
|
|
269
276
|
}
|
|
270
|
-
|
|
271
|
-
if (
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
277
|
+
|
|
278
|
+
if (planRes?.ok) {
|
|
279
|
+
const planData = await planRes.json().catch(() => ({}))
|
|
280
|
+
const plan = planData?.data || planData
|
|
281
|
+
if (plan?.title || plan?.name) {
|
|
282
|
+
planDisplayName = `${plan.title || plan.name} ($${((plan.priceInCents || 999) / 100).toFixed(2)}/mo)`
|
|
283
|
+
}
|
|
284
|
+
if (Array.isArray(plan?.includedModels)) {
|
|
285
|
+
dynamicModels = parsePlanIncludedModels(plan.includedModels)
|
|
275
286
|
}
|
|
276
287
|
}
|
|
288
|
+
} catch {
|
|
289
|
+
/* best-effort secondary details */
|
|
277
290
|
}
|
|
278
|
-
} catch {
|
|
279
|
-
/* ignore user metadata / plan failure */
|
|
280
|
-
}
|
|
281
291
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
292
|
+
const result = {
|
|
293
|
+
ok: true,
|
|
294
|
+
plan: planDisplayName,
|
|
295
|
+
user: { email: userEmail, createdAt },
|
|
296
|
+
windows: { fiveHour, weekly, monthly },
|
|
297
|
+
dynamicModels,
|
|
298
|
+
checkedAt: Date.now(),
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
usageCache.set(cacheKey, { data: result, expiresAt: Date.now() + ttlMs, isRevalidating: false })
|
|
302
|
+
return result
|
|
303
|
+
} catch (err) {
|
|
304
|
+
const outcome = {
|
|
305
|
+
ok: false,
|
|
306
|
+
error: String(err?.message || err),
|
|
307
|
+
checkedAt: Date.now(),
|
|
308
|
+
}
|
|
309
|
+
if (!usageCache.has(cacheKey)) {
|
|
310
|
+
usageCache.set(cacheKey, { data: outcome, expiresAt: Date.now() + 5000, isRevalidating: false })
|
|
311
|
+
}
|
|
312
|
+
return outcome
|
|
313
|
+
} finally {
|
|
314
|
+
cancel()
|
|
296
315
|
}
|
|
316
|
+
}
|
|
297
317
|
|
|
298
|
-
|
|
299
|
-
usageCache.
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
return {
|
|
303
|
-
ok: false,
|
|
304
|
-
error: String(err?.message || err),
|
|
318
|
+
if (!bypassCache && usageCache.has(cacheKey)) {
|
|
319
|
+
const cached = usageCache.get(cacheKey)
|
|
320
|
+
if (cached.expiresAt > now) {
|
|
321
|
+
return cached.data
|
|
305
322
|
}
|
|
306
|
-
|
|
307
|
-
|
|
323
|
+
// SWR: return stale data immediately, revalidate asynchronously
|
|
324
|
+
if (!cached.isRevalidating) {
|
|
325
|
+
cached.isRevalidating = true
|
|
326
|
+
doFetch().catch(() => {})
|
|
327
|
+
}
|
|
328
|
+
return cached.data
|
|
308
329
|
}
|
|
330
|
+
|
|
331
|
+
return doFetch()
|
|
309
332
|
}
|
|
310
333
|
|
|
311
334
|
/**
|
|
312
|
-
*
|
|
335
|
+
* Health check probe with SWR caching.
|
|
313
336
|
*/
|
|
314
|
-
export async function probeHealth(baseUrl, {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
337
|
+
export async function probeHealth(baseUrl, {
|
|
338
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
339
|
+
fetchImpl = fetch,
|
|
340
|
+
bypassCache = false,
|
|
341
|
+
ttlMs = 25000,
|
|
342
|
+
} = {}) {
|
|
343
|
+
const base = normalizeBaseUrl(baseUrl)
|
|
344
|
+
const root = base.replace(/\/api\/v1$/i, '')
|
|
345
|
+
const cacheKey = `cline:health:${root}`
|
|
346
|
+
const now = Date.now()
|
|
347
|
+
|
|
348
|
+
const doProbe = async () => {
|
|
349
|
+
const { signal, cancel } = abortAfter(timeoutMs)
|
|
350
|
+
const start = Date.now()
|
|
351
|
+
try {
|
|
352
|
+
const res = await retryWithBackoff(async () => {
|
|
353
|
+
return await fetchImpl(root, { method: 'HEAD', signal, keepalive: true })
|
|
354
|
+
})
|
|
355
|
+
const latencyMs = Date.now() - start
|
|
356
|
+
const reachable = res.status > 0 && res.status < 500
|
|
357
|
+
const outcome = {
|
|
358
|
+
ok: reachable,
|
|
359
|
+
status: res.status,
|
|
360
|
+
latencyMs,
|
|
361
|
+
error: reachable ? null : `HTTP status ${res.status}`,
|
|
362
|
+
checkedAt: Date.now(),
|
|
363
|
+
}
|
|
364
|
+
probeCache.set(cacheKey, { data: outcome, expiresAt: Date.now() + ttlMs, isRevalidating: false })
|
|
365
|
+
return outcome
|
|
366
|
+
} catch (err) {
|
|
367
|
+
const latencyMs = Date.now() - start
|
|
368
|
+
const outcome = {
|
|
369
|
+
ok: false,
|
|
370
|
+
latencyMs,
|
|
371
|
+
error: String(err?.message || err),
|
|
372
|
+
checkedAt: Date.now(),
|
|
373
|
+
}
|
|
374
|
+
probeCache.set(cacheKey, { data: outcome, expiresAt: Date.now() + 5000, isRevalidating: false })
|
|
375
|
+
return outcome
|
|
376
|
+
} finally {
|
|
377
|
+
cancel()
|
|
329
378
|
}
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (!bypassCache && probeCache.has(cacheKey)) {
|
|
382
|
+
const cached = probeCache.get(cacheKey)
|
|
383
|
+
if (cached.expiresAt > now) {
|
|
384
|
+
return cached.data
|
|
336
385
|
}
|
|
337
|
-
|
|
338
|
-
|
|
386
|
+
if (!cached.isRevalidating) {
|
|
387
|
+
cached.isRevalidating = true
|
|
388
|
+
doProbe().catch(() => {})
|
|
389
|
+
}
|
|
390
|
+
return cached.data
|
|
339
391
|
}
|
|
392
|
+
|
|
393
|
+
return doProbe()
|
|
340
394
|
}
|
|
341
395
|
|
|
342
396
|
/**
|
|
@@ -368,6 +422,7 @@ export async function smokeChat(baseUrl, apiKey, {
|
|
|
368
422
|
stream: false,
|
|
369
423
|
}),
|
|
370
424
|
signal,
|
|
425
|
+
keepalive: true,
|
|
371
426
|
})
|
|
372
427
|
|
|
373
428
|
const latencyMs = Date.now() - start
|
|
@@ -385,12 +440,18 @@ export async function smokeChat(baseUrl, apiKey, {
|
|
|
385
440
|
|
|
386
441
|
const payload = data?.data && typeof data.data === 'object' ? data.data : data
|
|
387
442
|
const content = payload?.choices?.[0]?.message?.content || payload?.choices?.[0]?.message?.reasoning
|
|
443
|
+
const promptTokens = Number(data?.usage?.prompt_tokens) || Number(payload?.usage?.prompt_tokens) || 0
|
|
444
|
+
const completionTokens = Number(data?.usage?.completion_tokens) || Number(payload?.usage?.completion_tokens) || 0
|
|
445
|
+
const totalTokens = Number(data?.usage?.total_tokens) || Number(payload?.usage?.total_tokens) || (promptTokens + completionTokens)
|
|
388
446
|
return {
|
|
389
447
|
ok: true,
|
|
390
448
|
status: res.status,
|
|
391
449
|
latencyMs,
|
|
392
450
|
model: payload?.model || model,
|
|
393
451
|
preview: typeof content === 'string' ? content.trim().slice(0, 150) : 'OK',
|
|
452
|
+
promptTokens,
|
|
453
|
+
completionTokens,
|
|
454
|
+
totalTokens,
|
|
394
455
|
}
|
|
395
456
|
} catch (err) {
|
|
396
457
|
const latencyMs = Date.now() - start
|
|
@@ -446,3 +507,151 @@ export function buildPiAiProvider({
|
|
|
446
507
|
models: modelList,
|
|
447
508
|
}
|
|
448
509
|
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Safely resolve key value from DSH credentials service or process.env.
|
|
513
|
+
*/
|
|
514
|
+
export async function resolveKeyValue(ctx, apiKeyEnv) {
|
|
515
|
+
const refName = String(apiKeyEnv || DEFAULT_API_KEY_ENV).trim() || DEFAULT_API_KEY_ENV
|
|
516
|
+
const creds = (ctx?.get && ctx.get('credentials')) || ctx?.credentials
|
|
517
|
+
if (creds && typeof creds.resolve === 'function') {
|
|
518
|
+
try {
|
|
519
|
+
const ref = await toCredentialRef(refName)
|
|
520
|
+
const hit = await creds.resolve(ref)
|
|
521
|
+
if (hit?.value) {
|
|
522
|
+
return { envName: refName, value: hit.value, source: 'credentials' }
|
|
523
|
+
}
|
|
524
|
+
} catch {
|
|
525
|
+
/* miss */
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const fromEnv = resolveApiKey(refName)
|
|
530
|
+
if (fromEnv.value) {
|
|
531
|
+
return { ...fromEnv, source: 'env' }
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return { envName: refName, value: '', source: 'none' }
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Resolve all accounts in pool with their status and keys.
|
|
539
|
+
*/
|
|
540
|
+
export async function resolveAccountPool(ctx, cfg) {
|
|
541
|
+
const apiKeyEnv = cfg?.apiKeyEnv || DEFAULT_API_KEY_ENV
|
|
542
|
+
const defaultSlot = {
|
|
543
|
+
id: 'default',
|
|
544
|
+
label: 'Default',
|
|
545
|
+
apiKeyEnv,
|
|
546
|
+
}
|
|
547
|
+
const accounts = Array.isArray(cfg?.accounts) ? cfg.accounts : []
|
|
548
|
+
const allSlots = [defaultSlot, ...accounts]
|
|
549
|
+
const activeAccount = String(cfg?.activeAccount || '')
|
|
550
|
+
const resolved = []
|
|
551
|
+
|
|
552
|
+
for (let i = 0; i < allSlots.length; i++) {
|
|
553
|
+
const slot = allSlots[i]
|
|
554
|
+
const envName = slot.apiKeyEnv || (i === 0 ? apiKeyEnv : `CLINEBOT_API_KEY_${i + 1}`)
|
|
555
|
+
const keyInfo = await resolveKeyValue(ctx, envName)
|
|
556
|
+
resolved.push({
|
|
557
|
+
id: slot.id || (i === 0 ? 'default' : `account-${i + 1}`),
|
|
558
|
+
label: slot.label || (i === 0 ? 'Default' : `Account ${i + 1}`),
|
|
559
|
+
apiKeyEnv: envName,
|
|
560
|
+
present: Boolean(keyInfo.value),
|
|
561
|
+
source: keyInfo.source,
|
|
562
|
+
value: keyInfo.value,
|
|
563
|
+
isPinned: activeAccount ? activeAccount === envName : i === 0,
|
|
564
|
+
})
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return resolved
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Check if a cached quota entry is currently exhausted.
|
|
572
|
+
* Checks 5-hour rolling limit and verifies if resetsAt timestamp has already elapsed.
|
|
573
|
+
*/
|
|
574
|
+
export function isAccountQuotaExhausted(usage) {
|
|
575
|
+
if (!usage?.windows?.fiveHour) return false
|
|
576
|
+
const fiveHour = usage.windows.fiveHour
|
|
577
|
+
if (typeof fiveHour.percentUsed !== 'number' || fiveHour.percentUsed < 95) {
|
|
578
|
+
return false
|
|
579
|
+
}
|
|
580
|
+
// Auto-recovery: if resetsAt is present and in the past, the account is recovered
|
|
581
|
+
if (fiveHour.resetsAt) {
|
|
582
|
+
const resetTime = new Date(fiveHour.resetsAt).getTime()
|
|
583
|
+
if (!Number.isNaN(resetTime) && Date.now() >= resetTime) {
|
|
584
|
+
return false
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return true
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Smart Quota-Aware Failover: rotates active account upon 429 or quota exhaustion.
|
|
592
|
+
* Prioritizes accounts with lowest percentUsed and respects resetsAt recovery.
|
|
593
|
+
*/
|
|
594
|
+
export async function rotateToNextAccount(ctx, cfg, reason = 'rate_limit', settingsApi = null) {
|
|
595
|
+
const pool = await resolveAccountPool(ctx, cfg)
|
|
596
|
+
const configured = pool.filter((acc) => acc.present && acc.value)
|
|
597
|
+
if (configured.length <= 1) {
|
|
598
|
+
return { rotated: false, reason, message: 'Pool has only 1 configured account' }
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const active = String(cfg?.activeAccount || configured[0].apiKeyEnv)
|
|
602
|
+
const currentIndex = configured.findIndex((acc) => acc.apiKeyEnv === active)
|
|
603
|
+
|
|
604
|
+
// Candidate pool excluding current account if possible
|
|
605
|
+
const candidates = configured.filter((acc) => acc.apiKeyEnv !== active)
|
|
606
|
+
if (!candidates.length) {
|
|
607
|
+
return { rotated: false, reason, message: 'No alternative accounts configured' }
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Assess quota for candidates if cached in memory
|
|
611
|
+
let bestCandidate = null
|
|
612
|
+
let lowestUsagePct = Infinity
|
|
613
|
+
|
|
614
|
+
for (const cand of candidates) {
|
|
615
|
+
const cacheKey = `cline:usage:${cand.value.slice(-8)}`
|
|
616
|
+
const cached = usageCache.get(cacheKey)?.data
|
|
617
|
+
const isExhausted = isAccountQuotaExhausted(cached)
|
|
618
|
+
|
|
619
|
+
if (!isExhausted) {
|
|
620
|
+
const pct = cached?.windows?.fiveHour?.percentUsed ?? 50
|
|
621
|
+
if (pct < lowestUsagePct) {
|
|
622
|
+
lowestUsagePct = pct
|
|
623
|
+
bestCandidate = cand
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Fallback if all candidates are either exhausted or uncached: pick next in round-robin
|
|
629
|
+
const nextAcc = bestCandidate || candidates[currentIndex % candidates.length] || candidates[0]
|
|
630
|
+
|
|
631
|
+
let updated = false
|
|
632
|
+
if (settingsApi?.replace) {
|
|
633
|
+
try {
|
|
634
|
+
const next = { ...cfg, activeAccount: nextAcc.apiKeyEnv }
|
|
635
|
+
await settingsApi.replace(next)
|
|
636
|
+
updated = true
|
|
637
|
+
} catch {}
|
|
638
|
+
} else {
|
|
639
|
+
const settings = (ctx?.get && ctx.get('settings')) || ctx?.settings
|
|
640
|
+
if (settings?.mutate) {
|
|
641
|
+
try {
|
|
642
|
+
await settings.mutate('dsh-clinebot', [
|
|
643
|
+
{ op: 'set', path: ['activeAccount'], value: nextAcc.apiKeyEnv },
|
|
644
|
+
])
|
|
645
|
+
updated = true
|
|
646
|
+
} catch {}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
return {
|
|
651
|
+
rotated: true,
|
|
652
|
+
previousAccount: active,
|
|
653
|
+
activeAccount: nextAcc.apiKeyEnv,
|
|
654
|
+
reason,
|
|
655
|
+
updatedSettings: updated,
|
|
656
|
+
}
|
|
657
|
+
}
|