@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.
@@ -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,14 @@ 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()
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
- * Endpoints:
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
- if (!bypassCache && usageCache.has(cacheKey)) {
194
- const cached = usageCache.get(cacheKey)
195
- if (cached.expiresAt > now) {
196
- return cached.data
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
- const parseWindow = (type) => {
227
- const found = Array.isArray(rawLimits) ? rawLimits.find((l) => l.type === type) : null
228
- if (!found) return null
229
- const percentUsed = typeof found.percentUsed === 'number'
230
- ? Math.max(0, Math.min(100, Math.round(found.percentUsed * 10) / 10))
231
- : 0
232
- const remainingPercent = Math.max(0, Math.round((100 - percentUsed) * 10) / 10)
233
- return {
234
- type,
235
- percentUsed,
236
- remainingPercent,
237
- resetsAt: found.resetsAt || null,
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
- const fiveHour = parseWindow('5-hour')
242
- const weekly = parseWindow('weekly')
243
- const monthly = parseWindow('monthly')
244
-
245
- // 2. Fetch user metadata and plan details (optional, best-effort)
246
- let userEmail = null
247
- let createdAt = null
248
- let planDisplayName = 'ClinePass ($9.99/mo)'
249
- let dynamicModels = []
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
- try {
252
- const [meRes, planRes] = await Promise.all([
253
- fetchImpl(`${base}/users/me`, { method: 'GET', headers, signal }).catch(() => null),
254
- fetchImpl(`${base}/users/me/plan`, { method: 'GET', headers, signal }).catch(() => null),
255
- ])
256
-
257
- if (meRes && meRes.ok) {
258
- const meData = await meRes.json().catch(() => ({}))
259
- const me = meData?.data || meData?.user || meData
260
- userEmail = me?.email || null
261
- createdAt = me?.createdAt || 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
+ }
262
255
  }
263
256
 
264
- if (planRes && planRes.ok) {
265
- const planData = await planRes.json().catch(() => ({}))
266
- const planObj = planData?.data?.plan || planData?.plan
267
- if (planObj?.displayName) {
268
- planDisplayName = planObj.displayName
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
- const includedArr = planObj?.features?.included
271
- if (Array.isArray(includedArr)) {
272
- const incStr = includedArr.find((s) => typeof s === 'string' && s.toLowerCase().includes('includes'))
273
- if (incStr) {
274
- 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)
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
- const result = {
283
- ok: true,
284
- plan: planDisplayName,
285
- user: {
286
- email: userEmail,
287
- createdAt,
288
- },
289
- windows: {
290
- fiveHour: fiveHour || { type: '5-hour', percentUsed: 0, remainingPercent: 100, resetsAt: null },
291
- weekly: weekly || { type: 'weekly', percentUsed: 0, remainingPercent: 100, resetsAt: null },
292
- monthly: monthly || { type: 'monthly', percentUsed: 0, remainingPercent: 100, resetsAt: null },
293
- },
294
- dynamicModels,
295
- 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()
296
315
  }
316
+ }
297
317
 
298
- // Cache for 60 seconds
299
- usageCache.set(cacheKey, { expiresAt: now + 60000, data: result })
300
- return result
301
- } catch (err) {
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
- } finally {
307
- 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
308
329
  }
330
+
331
+ return doFetch()
309
332
  }
310
333
 
311
334
  /**
312
- * Quick network probe to verify server availability.
335
+ * Health check probe with SWR caching.
313
336
  */
314
- export async function probeHealth(baseUrl, { timeoutMs = DEFAULT_TIMEOUT_MS, fetchImpl = fetch } = {}) {
315
- const root = normalizeBaseUrl(baseUrl)
316
- const { signal, cancel } = abortAfter(timeoutMs)
317
- const start = Date.now()
318
- try {
319
- const res = await fetchImpl(root, { method: 'GET', signal }).catch(async () => {
320
- return await fetchImpl(root, { method: 'HEAD', signal })
321
- })
322
- const latencyMs = Date.now() - start
323
- const reachable = res.status > 0 && res.status < 500
324
- return {
325
- ok: reachable,
326
- status: res.status,
327
- latencyMs,
328
- error: reachable ? null : `HTTP status ${res.status}`,
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
- } catch (err) {
331
- const latencyMs = Date.now() - start
332
- return {
333
- ok: false,
334
- latencyMs,
335
- error: String(err?.message || err),
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
- } finally {
338
- cancel()
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
+ }