@goodandready/dsh-clinebot 0.3.8 → 0.3.10

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.
@@ -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 via DSH credentials service.
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,8 @@ function abortAfter(ms) {
130
131
  return { signal: ac.signal, cancel: () => clearTimeout(timer) }
131
132
  }
132
133
 
133
- // In-memory cache for quota queries to avoid hammering the ClinePass endpoint
134
- const usageCache = new Map()
134
+ // In-memory caches for quota queries and host health probes
135
+ export const usageCache = new Map()
135
136
  export const probeCache = new Map()
136
137
 
137
138
  export function clearProbeCache() {
@@ -155,7 +156,6 @@ export async function retryWithBackoff(fn, {
155
156
  while (true) {
156
157
  try {
157
158
  const res = await fn()
158
- // If HTTP response-like object with 5xx or 429 status and can retry
159
159
  if (res && typeof res.status === 'number' && [429, 502, 503, 504].includes(res.status) && attempt < maxRetries) {
160
160
  attempt++
161
161
  const retryAfter = res.headers?.get ? Number(res.headers.get('retry-after')) * 1000 : 0
@@ -179,14 +179,13 @@ export async function retryWithBackoff(fn, {
179
179
 
180
180
  /**
181
181
  * Fetch official ClinePass rate limits and account quota.
182
- * Endpoints:
183
- * - GET /users/me/plan/usage-limits (5-hour, weekly, monthly rolling limits)
184
- * - GET /users/me (account metadata)
182
+ * Uses Stale-While-Revalidate (SWR) caching with keep-alive and network retry.
185
183
  */
186
184
  export async function fetchUsageLimits(baseUrl, apiKey, {
187
185
  timeoutMs = DEFAULT_TIMEOUT_MS,
188
186
  fetchImpl = fetch,
189
187
  bypassCache = false,
188
+ ttlMs = USAGE_CACHE_TTL_MS,
190
189
  } = {}) {
191
190
  const base = normalizeBaseUrl(baseUrl)
192
191
  if (!apiKey) {
@@ -196,126 +195,144 @@ export async function fetchUsageLimits(baseUrl, apiKey, {
196
195
  const cacheKey = `cline:usage:${apiKey.slice(-8)}`
197
196
  const now = Date.now()
198
197
 
199
- if (!bypassCache && usageCache.has(cacheKey)) {
200
- const cached = usageCache.get(cacheKey)
201
- if (cached.expiresAt > now) {
202
- return cached.data
203
- }
204
- }
205
-
206
- const { signal, cancel } = abortAfter(timeoutMs)
207
- try {
208
- const headers = {
209
- Authorization: `Bearer ${apiKey}`,
210
- Accept: 'application/json',
211
- }
212
-
213
- // 1. Fetch usage limits
214
- const limitsRes = await fetchImpl(`${base}/users/me/plan/usage-limits`, {
215
- method: 'GET',
216
- headers,
217
- signal,
218
- })
198
+ const doFetch = async () => {
199
+ const { signal, cancel } = abortAfter(timeoutMs)
200
+ try {
201
+ const headers = {
202
+ Authorization: `Bearer ${apiKey}`,
203
+ Accept: 'application/json',
204
+ }
219
205
 
220
- if (!limitsRes.ok) {
221
- const errText = await limitsRes.text().catch(() => '')
222
- return {
223
- ok: false,
224
- status: limitsRes.status,
225
- error: `ClinePass limits error (HTTP ${limitsRes.status}): ${errText.slice(0, 150)}`,
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
+ })
226
223
  }
227
- }
228
224
 
229
- const limitsData = await limitsRes.json().catch(() => ({}))
230
- const rawLimits = limitsData?.data?.limits || limitsData?.limits || []
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
+ }
231
238
 
232
- const parseWindow = (type) => {
233
- const found = Array.isArray(rawLimits) ? rawLimits.find((l) => l.type === type) : null
234
- if (!found) return null
235
- const percentUsed = typeof found.percentUsed === 'number'
236
- ? Math.max(0, Math.min(100, Math.round(found.percentUsed * 10) / 10))
237
- : 0
238
- const remainingPercent = Math.max(0, Math.round((100 - percentUsed) * 10) / 10)
239
- return {
240
- type,
241
- percentUsed,
242
- remainingPercent,
243
- resetsAt: found.resetsAt || null,
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
+ }
244
255
  }
245
- }
246
256
 
247
- const fiveHour = parseWindow('5-hour')
248
- const weekly = parseWindow('weekly')
249
- const monthly = parseWindow('monthly')
257
+ const fiveHour = parseWindow('5-hour')
258
+ const weekly = parseWindow('weekly')
259
+ const monthly = parseWindow('monthly')
250
260
 
251
- // 2. Fetch user metadata and plan details (optional, best-effort)
252
- let userEmail = null
253
- let createdAt = null
254
- let planDisplayName = 'ClinePass ($9.99/mo)'
255
- let dynamicModels = []
261
+ let userEmail = null
262
+ let createdAt = null
263
+ let planDisplayName = 'ClinePass ($9.99/mo)'
264
+ let dynamicModels = []
256
265
 
257
- try {
258
- const [meRes, planRes] = await Promise.all([
259
- fetchImpl(`${base}/users/me`, { method: 'GET', headers, signal }).catch(() => null),
260
- fetchImpl(`${base}/users/me/plan`, { method: 'GET', headers, signal }).catch(() => null),
261
- ])
262
-
263
- if (meRes && meRes.ok) {
264
- const meData = await meRes.json().catch(() => ({}))
265
- const me = meData?.data || meData?.user || meData
266
- userEmail = me?.email || null
267
- createdAt = me?.createdAt || null
268
- }
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
+ ])
269
271
 
270
- if (planRes && planRes.ok) {
271
- const planData = await planRes.json().catch(() => ({}))
272
- const planObj = planData?.data?.plan || planData?.plan
273
- if (planObj?.displayName) {
274
- planDisplayName = planObj.displayName
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
275
276
  }
276
- const includedArr = planObj?.features?.included
277
- if (Array.isArray(includedArr)) {
278
- const incStr = includedArr.find((s) => typeof s === 'string' && s.toLowerCase().includes('includes'))
279
- if (incStr) {
280
- dynamicModels = parsePlanIncludedModels(incStr)
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)
281
286
  }
282
287
  }
288
+ } catch {
289
+ /* best-effort secondary details */
283
290
  }
284
- } catch {
285
- /* ignore user metadata / plan failure */
286
- }
287
291
 
288
- const result = {
289
- ok: true,
290
- plan: planDisplayName,
291
- user: {
292
- email: userEmail,
293
- createdAt,
294
- },
295
- windows: {
296
- fiveHour: fiveHour || { type: '5-hour', percentUsed: 0, remainingPercent: 100, resetsAt: null },
297
- weekly: weekly || { type: 'weekly', percentUsed: 0, remainingPercent: 100, resetsAt: null },
298
- monthly: monthly || { type: 'monthly', percentUsed: 0, remainingPercent: 100, resetsAt: null },
299
- },
300
- dynamicModels,
301
- checkedAt: now,
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()
302
315
  }
316
+ }
303
317
 
304
- // Cache for 60 seconds
305
- usageCache.set(cacheKey, { expiresAt: now + 60000, data: result })
306
- return result
307
- } catch (err) {
308
- return {
309
- ok: false,
310
- 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
311
322
  }
312
- } finally {
313
- cancel()
323
+ // SWR: return stale data immediately, revalidate asynchronously
324
+ if (!cached.isRevalidating) {
325
+ cached.isRevalidating = true
326
+ doFetch().catch(() => {})
327
+ }
328
+ return cached.data
314
329
  }
330
+
331
+ return doFetch()
315
332
  }
316
333
 
317
334
  /**
318
- * Quick network probe to verify server availability with Stale-While-Revalidate caching.
335
+ * Health check probe with SWR caching.
319
336
  */
320
337
  export async function probeHealth(baseUrl, {
321
338
  timeoutMs = DEFAULT_TIMEOUT_MS,
@@ -323,15 +340,16 @@ export async function probeHealth(baseUrl, {
323
340
  bypassCache = false,
324
341
  ttlMs = 25000,
325
342
  } = {}) {
326
- const root = normalizeBaseUrl(baseUrl)
327
- const cacheKey = `probe:${root}`
343
+ const base = normalizeBaseUrl(baseUrl)
344
+ const root = base.replace(/\/api\/v1$/i, '')
345
+ const cacheKey = `cline:health:${root}`
328
346
  const now = Date.now()
329
347
 
330
348
  const doProbe = async () => {
331
349
  const { signal, cancel } = abortAfter(timeoutMs)
332
350
  const start = Date.now()
333
351
  try {
334
- const res = await fetchImpl(root, { method: 'GET', signal, keepalive: true }).catch(async () => {
352
+ const res = await retryWithBackoff(async () => {
335
353
  return await fetchImpl(root, { method: 'HEAD', signal, keepalive: true })
336
354
  })
337
355
  const latencyMs = Date.now() - start
@@ -365,7 +383,6 @@ export async function probeHealth(baseUrl, {
365
383
  if (cached.expiresAt > now) {
366
384
  return cached.data
367
385
  }
368
- // Stale-While-Revalidate: trigger async refresh and return stale snapshot immediately
369
386
  if (!cached.isRevalidating) {
370
387
  cached.isRevalidating = true
371
388
  doProbe().catch(() => {})
@@ -476,8 +493,27 @@ export function buildPiAiProvider({
476
493
  input: hasImage ? ['text', 'image'] : ['text'],
477
494
  provider: PROVIDER_ID,
478
495
  }
496
+ const validEfforts = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
479
497
  if (Array.isArray(item.reasoningEfforts) && item.reasoningEfforts.length) {
480
- res.reasoningEfforts = [...item.reasoningEfforts]
498
+ const efforts = {}
499
+ for (const effort of item.reasoningEfforts) {
500
+ const key = String(effort).trim().toLowerCase()
501
+ if (validEfforts.includes(key)) {
502
+ efforts[key] = key
503
+ }
504
+ }
505
+ res.reasoningEfforts = Object.keys(efforts).length ? efforts : false
506
+ } else if (item.reasoningEfforts && typeof item.reasoningEfforts === 'object' && !Array.isArray(item.reasoningEfforts)) {
507
+ const efforts = {}
508
+ for (const [k, v] of Object.entries(item.reasoningEfforts)) {
509
+ const key = String(k).trim().toLowerCase()
510
+ if (validEfforts.includes(key)) {
511
+ efforts[key] = typeof v === 'string' && v.trim() ? v.trim() : key
512
+ }
513
+ }
514
+ res.reasoningEfforts = Object.keys(efforts).length ? efforts : false
515
+ } else {
516
+ res.reasoningEfforts = false
481
517
  }
482
518
  return res
483
519
  })
@@ -492,7 +528,7 @@ export function buildPiAiProvider({
492
528
  }
493
529
 
494
530
  /**
495
- * Safely resolve key value from DSH credentials or process.env.
531
+ * Safely resolve key value from DSH credentials service or process.env.
496
532
  */
497
533
  export async function resolveKeyValue(ctx, apiKeyEnv) {
498
534
  const refName = String(apiKeyEnv || DEFAULT_API_KEY_ENV).trim() || DEFAULT_API_KEY_ENV
@@ -551,7 +587,28 @@ export async function resolveAccountPool(ctx, cfg) {
551
587
  }
552
588
 
553
589
  /**
554
- * Rotate active account to next available configured account upon rate-limiting (429) or quota exhaustion.
590
+ * Check if a cached quota entry is currently exhausted.
591
+ * Checks 5-hour rolling limit and verifies if resetsAt timestamp has already elapsed.
592
+ */
593
+ export function isAccountQuotaExhausted(usage) {
594
+ if (!usage?.windows?.fiveHour) return false
595
+ const fiveHour = usage.windows.fiveHour
596
+ if (typeof fiveHour.percentUsed !== 'number' || fiveHour.percentUsed < 95) {
597
+ return false
598
+ }
599
+ // Auto-recovery: if resetsAt is present and in the past, the account is recovered
600
+ if (fiveHour.resetsAt) {
601
+ const resetTime = new Date(fiveHour.resetsAt).getTime()
602
+ if (!Number.isNaN(resetTime) && Date.now() >= resetTime) {
603
+ return false
604
+ }
605
+ }
606
+ return true
607
+ }
608
+
609
+ /**
610
+ * Smart Quota-Aware Failover: rotates active account upon 429 or quota exhaustion.
611
+ * Prioritizes accounts with lowest percentUsed and respects resetsAt recovery.
555
612
  */
556
613
  export async function rotateToNextAccount(ctx, cfg, reason = 'rate_limit', settingsApi = null) {
557
614
  const pool = await resolveAccountPool(ctx, cfg)
@@ -562,8 +619,33 @@ export async function rotateToNextAccount(ctx, cfg, reason = 'rate_limit', setti
562
619
 
563
620
  const active = String(cfg?.activeAccount || configured[0].apiKeyEnv)
564
621
  const currentIndex = configured.findIndex((acc) => acc.apiKeyEnv === active)
565
- const nextIndex = (currentIndex + 1) % configured.length
566
- const nextAcc = configured[nextIndex]
622
+
623
+ // Candidate pool excluding current account if possible
624
+ const candidates = configured.filter((acc) => acc.apiKeyEnv !== active)
625
+ if (!candidates.length) {
626
+ return { rotated: false, reason, message: 'No alternative accounts configured' }
627
+ }
628
+
629
+ // Assess quota for candidates if cached in memory
630
+ let bestCandidate = null
631
+ let lowestUsagePct = Infinity
632
+
633
+ for (const cand of candidates) {
634
+ const cacheKey = `cline:usage:${cand.value.slice(-8)}`
635
+ const cached = usageCache.get(cacheKey)?.data
636
+ const isExhausted = isAccountQuotaExhausted(cached)
637
+
638
+ if (!isExhausted) {
639
+ const pct = cached?.windows?.fiveHour?.percentUsed ?? 50
640
+ if (pct < lowestUsagePct) {
641
+ lowestUsagePct = pct
642
+ bestCandidate = cand
643
+ }
644
+ }
645
+ }
646
+
647
+ // Fallback if all candidates are either exhausted or uncached: pick next in round-robin
648
+ const nextAcc = bestCandidate || candidates[currentIndex % candidates.length] || candidates[0]
567
649
 
568
650
  let updated = false
569
651
  if (settingsApi?.replace) {
@@ -571,7 +653,9 @@ export async function rotateToNextAccount(ctx, cfg, reason = 'rate_limit', setti
571
653
  const next = { ...cfg, activeAccount: nextAcc.apiKeyEnv }
572
654
  await settingsApi.replace(next)
573
655
  updated = true
574
- } catch {}
656
+ } catch (err) {
657
+ ctx?.logger?.warn?.('[dsh-clinebot] Failed to persist rotated activeAccount via settingsApi: ' + (err?.message || err))
658
+ }
575
659
  } else {
576
660
  const settings = (ctx?.get && ctx.get('settings')) || ctx?.settings
577
661
  if (settings?.mutate) {
@@ -580,10 +664,16 @@ export async function rotateToNextAccount(ctx, cfg, reason = 'rate_limit', setti
580
664
  { op: 'set', path: ['activeAccount'], value: nextAcc.apiKeyEnv },
581
665
  ])
582
666
  updated = true
583
- } catch {}
667
+ } catch (err) {
668
+ ctx?.logger?.warn?.('[dsh-clinebot] Failed to persist rotated activeAccount via settings.mutate: ' + (err?.message || err))
669
+ }
584
670
  }
585
671
  }
586
672
 
673
+ // Reset cached quota and host probes so new account immediately revalidates
674
+ clearUsageCache()
675
+ clearProbeCache()
676
+
587
677
  return {
588
678
  rotated: true,
589
679
  previousAccount: active,
package/lib/http.js CHANGED
@@ -28,7 +28,67 @@ export function readBody(req, maxBytes = 256 * 1024) {
28
28
  })
29
29
  }
30
30
 
31
- /** Reject cross-site writes. LAN / reverse-proxy UIs are allowed. */
31
+ function header(request, name) {
32
+ const value = request?.headers?.[name.toLowerCase()]
33
+ return Array.isArray(value) ? value[0] : value
34
+ }
35
+
36
+ function isLoopbackAddress(value) {
37
+ const address = String(value || '').toLowerCase().replace(/^\[|\]$/g, '')
38
+ return address === 'localhost' || address === 'localhost.' || address === '::1'
39
+ || address.startsWith('127.')
40
+ || address.startsWith('::ffff:127.')
41
+ }
42
+
43
+ /** Reject cross-site writes while allowing local, LAN, and reverse-proxy UIs. */
32
44
  export function isTrustedSettingsRequest(request) {
33
- return request.headers['sec-fetch-site'] !== 'cross-site'
45
+ const secFetchSite = header(request, 'sec-fetch-site')
46
+ if (secFetchSite === 'cross-site') {
47
+ return false
48
+ }
49
+
50
+ const host = header(request, 'x-forwarded-host') || header(request, 'host')
51
+ const origin = header(request, 'origin')
52
+ if (origin) {
53
+ try {
54
+ const url = new URL(origin)
55
+ if (host && url.host.toLowerCase() === host.toLowerCase()) {
56
+ return true
57
+ }
58
+ if (isLoopbackAddress(url.hostname) && isLoopbackAddress(request?.socket?.remoteAddress)) {
59
+ return true
60
+ }
61
+ return false
62
+ } catch {
63
+ return false
64
+ }
65
+ }
66
+
67
+ const referer = header(request, 'referer')
68
+ if (referer) {
69
+ try {
70
+ const url = new URL(referer)
71
+ if (host && url.host.toLowerCase() === host.toLowerCase()) {
72
+ return true
73
+ }
74
+ if (isLoopbackAddress(url.hostname) && isLoopbackAddress(request?.socket?.remoteAddress)) {
75
+ return true
76
+ }
77
+ return false
78
+ } catch {
79
+ return false
80
+ }
81
+ }
82
+
83
+ // Requests without origin/referer (e.g. curl or internal requests): allow if loopback
84
+ if (isLoopbackAddress(request?.socket?.remoteAddress)) {
85
+ return true
86
+ }
87
+
88
+ // If sec-fetch-site is explicitly same-origin or same-site, allow
89
+ if (secFetchSite === 'same-origin' || secFetchSite === 'same-site') {
90
+ return true
91
+ }
92
+
93
+ return false
34
94
  }