@goodandready/dsh-subscriptions 0.6.38 → 0.6.39

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/lib/accounts.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { needsWarmupProbe, enterProbing, resolveWarmupSuccess, resolveWarmupFailure, STATUS_QUARANTINE, STATUS_PROBING } from "./quarantine.js"
1
2
  import { fetchWithTimeout } from "./http.js"
2
3
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
3
4
  import { oauthRef, isProvider } from './refs.js'
@@ -348,19 +349,51 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
348
349
  quarantines.delete(ref)
349
350
  if (health.has(ref)) health.set(ref, null)
350
351
  },
351
- rememberQuarantine(ref, reason, until) {
352
+ rememberQuarantine(ref, reason, until, attempts = 1) {
352
353
  if (!ref) return
353
- quarantines.set(ref, { reason: reason || 'RATE_LIMIT', until: Number(until) || (Date.now() + 3600000) })
354
+ quarantines.set(ref, {
355
+ reason: reason || 'RATE_LIMIT',
356
+ until: Number(until) || (Date.now() + 3600000),
357
+ attempts: Number(attempts) || 1,
358
+ status: STATUS_QUARANTINE,
359
+ })
354
360
  },
355
- getQuarantine(ref) {
361
+ getQuarantine(ref, now = Date.now()) {
356
362
  const q = quarantines.get(ref)
357
363
  if (!q) return null
358
- if (Number(q.until) <= Date.now()) {
364
+ if (Number(q.until) <= now) {
365
+ if (q.status === STATUS_PROBING) return q
359
366
  quarantines.delete(ref)
360
367
  return null
361
368
  }
362
369
  return q
363
370
  },
371
+ async probeWarmup(ref, probeFn) {
372
+ const q = quarantines.get(ref)
373
+ if (!q) return { success: true }
374
+ const acc = { quarantineReason: q.reason, quarantineUntil: q.until, quarantineAttempts: q.attempts || 1, status: q.status }
375
+ if (!needsWarmupProbe(acc)) {
376
+ return { success: false, reason: 'NOT_EXPIRED' }
377
+ }
378
+ quarantines.set(ref, enterProbing(acc))
379
+ try {
380
+ if (typeof probeFn === 'function') {
381
+ await probeFn(ref)
382
+ }
383
+ resolveWarmupSuccess(acc)
384
+ quarantines.delete(ref)
385
+ return { success: true }
386
+ } catch (err) {
387
+ const updated = resolveWarmupFailure(acc, err?.message || q.reason)
388
+ quarantines.set(ref, {
389
+ reason: updated.quarantineReason,
390
+ until: updated.quarantineUntil,
391
+ attempts: updated.quarantineAttempts,
392
+ status: updated.status,
393
+ })
394
+ return { success: false, account: updated, error: err }
395
+ }
396
+ },
364
397
  releaseQuarantine(ref) {
365
398
  quarantines.delete(ref)
366
399
  },
@@ -21,6 +21,7 @@ export function createAdapterManager({
21
21
  }) {
22
22
  let handle
23
23
  let adapterDisposed = false
24
+ let retryTimer
24
25
  let registeredByUs = new Set()
25
26
 
26
27
  function disposeAdapterHandle(h) {
@@ -48,6 +49,11 @@ export function createAdapterManager({
48
49
  saveBlob: (ref, blob) => store.saveBlob(ref, blob),
49
50
  alertThresholds: () => live().alertThresholds,
50
51
  webhookUrl: () => live().webhookUrl,
52
+ rememberRequest: (ref) => store.rememberRequest(ref),
53
+ recordSuccess: (ref) => store.recordSuccess(ref),
54
+ recordSwitch: (ref) => store.recordSwitch(ref),
55
+ recordExhaust: (ref) => store.recordExhaust(ref),
56
+ recordBroken: (ref) => store.recordBroken(ref),
51
57
  recordHistory,
52
58
  fetchImpl: fetch,
53
59
  fetchForRef,
@@ -83,6 +89,7 @@ export function createAdapterManager({
83
89
  async function syncAdapter() {
84
90
  if (adapterDisposed) return
85
91
  const desired = await store.loggedInProviders()
92
+ if (adapterDisposed) return
86
93
  const globalProviders = ctx.llm && typeof ctx.llm.listProviders === 'function'
87
94
  ? ctx.llm.listProviders().map((p) => p.id)
88
95
  : []
@@ -95,6 +102,7 @@ export function createAdapterManager({
95
102
  logger.info(`[dsh-subscriptions] syncAdapter skipping already-bound providers: ${skipped.join(', ')}`)
96
103
  }
97
104
 
105
+ if (adapterDisposed) return
98
106
  if (!handle && claimable.length) {
99
107
  handle = ctx.llm.registerAdapter(claimable, adapter)
100
108
  registeredByUs = new Set(claimable)
@@ -239,7 +247,6 @@ export function createAdapterManager({
239
247
  })
240
248
 
241
249
  let retries = 0
242
- let retryTimer
243
250
  const scheduleRetry = () => {
244
251
  if (adapterDisposed || retries >= 30) return
245
252
  retryTimer = setTimeout(retrySync, 1000)
@@ -303,5 +310,15 @@ export function createAdapterManager({
303
310
  refreshModels,
304
311
  cascadeFallbackStream,
305
312
  registerAdapterLifecycle,
313
+ dispose: () => {
314
+ adapterDisposed = true
315
+ adapter.dispose()
316
+ if (retryTimer) clearTimeout(retryTimer)
317
+ if (handle) {
318
+ disposeAdapterHandle(handle)
319
+ handle = undefined
320
+ registeredByUs.clear()
321
+ }
322
+ },
306
323
  }
307
324
  }
package/lib/adapter.js CHANGED
@@ -32,6 +32,11 @@ export class SubscriptionAdapter extends LlmAdapter {
32
32
  constructor(deps) {
33
33
  super()
34
34
  this.deps = deps
35
+ this.disposed = false
36
+ }
37
+
38
+ dispose() {
39
+ this.disposed = true
35
40
  }
36
41
 
37
42
  providerInfo(provider) {
@@ -238,6 +243,11 @@ export class SubscriptionAdapter extends LlmAdapter {
238
243
  } catch { /* ignore quota capture failure */ }
239
244
  }
240
245
  } catch (err) {
246
+ if (err && (err.code === 'QUOTA' || err.code === 'QUOTA_EXCEEDED' || err.status === 429)) {
247
+ if (typeof deps.recordExhaust === 'function') deps.recordExhaust(account.ref)
248
+ } else if (err && (err.status === 401 || err.status === 403 || err.code === 'TOKEN_REVOKED' || err.code === 'BROKEN')) {
249
+ if (typeof deps.recordBroken === 'function') deps.recordBroken(account.ref)
250
+ }
241
251
  if (err && err.code === 'VALIDATION_REQUIRED' && err.validationUrl) {
242
252
  await deps.saveBlob(account.ref, {
243
253
  ...blob,
@@ -275,7 +285,7 @@ export class SubscriptionAdapter extends LlmAdapter {
275
285
  }
276
286
  // #91: when the whole pool is exhausted and nothing has been streamed
277
287
  // yet, continue seamlessly on local Ollama instead of failing the chat.
278
- if (!yieldedAny && typeof deps.ollamaFallback === 'function') {
288
+ if (!yieldedAny && !options.signal?.aborted && typeof deps.ollamaFallback === 'function') {
279
289
  yield* deps.ollamaFallback({ options, provider, err })
280
290
  return
281
291
  }
package/lib/cascade.js CHANGED
@@ -9,6 +9,16 @@ export const DEFAULT_CASCADE_CHAINS = {
9
9
  copilot: ['cursor', 'codex'],
10
10
  cursor: ['copilot', 'claude'],
11
11
  antigravity: ['copilot', 'claude'],
12
+ kimi: ['qwen', 'glm', 'spark'],
13
+ glm: ['kimi', 'qwen', 'spark'],
14
+ qwen: ['kimi', 'glm', 'spark'],
15
+ spark: ['qwen', 'kimi', 'glm'],
16
+ jetbrains: ['copilot', 'cursor'],
17
+ perplexity: ['copilot', 'cursor'],
18
+ replit: ['copilot', 'cursor'],
19
+ cody: ['copilot', 'cursor'],
20
+ kiro: ['copilot', 'cursor'],
21
+ ernie: ['qwen', 'glm'],
12
22
  }
13
23
 
14
24
  /**
package/lib/rotate.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { computePacingRisk, calculateBurnRatePerHour } from "./forecast.js"
1
2
  import { isQuarantined } from './quarantine.js'
2
3
  import { getPinnedAccountRef, pinSession } from './session-pin.js'
3
4
 
@@ -43,7 +44,7 @@ export function cooldownBlocks(acc, family) {
43
44
  export function pickAccount(accounts, nowMs, opts) {
44
45
  const list = Array.isArray(accounts) ? accounts : []
45
46
  const now = Number(nowMs) || 0
46
- const thrRaw = opts && opts.switchAtRemaining != null ? Number(opts.switchAtRemaining) : 0
47
+ const thrRaw = opts && opts.switchAtRemaining != null ? Number(opts.switchAtRemaining) : 0.01
47
48
  const thr = Number.isFinite(thrRaw) ? thrRaw : 0
48
49
  const isVipRequest = Boolean(opts && opts.vip)
49
50
  const targetTag = opts && opts.tag
@@ -92,11 +93,20 @@ export function pickAccount(accounts, nowMs, opts) {
92
93
  // Tier 1: Quota unknown (null) & healthy
93
94
  // Tier 2: Cooldown, quarantine or exhausted
94
95
  const tiers = [[], [], []]
95
- const fallback = []
96
96
 
97
97
  for (const acc of list) {
98
98
  if (!acc || !acc.hasToken) continue
99
99
 
100
+ if (!opts || opts.autoPacing !== false) {
101
+ if (acc.pacingRisk === undefined) {
102
+ const rem = acc.quota?.remainingPercent ?? (acc.usagePercent != null ? (100 - Number(acc.usagePercent)) : null)
103
+ const pace = acc.pacePerHour || (Array.isArray(acc.samples) ? calculateBurnRatePerHour(acc.samples, now) : 0)
104
+ const resetAt = acc.quota?.resetAt || 0
105
+ const riskInfo = computePacingRisk(rem, pace, resetAt, now)
106
+ acc.pacingRisk = riskInfo.pacingRisk
107
+ }
108
+ }
109
+
100
110
  // VIP Slot Reservation (#175)
101
111
  if (acc.vipOnly && !isVipRequest) continue
102
112
 
@@ -105,11 +115,16 @@ export function pickAccount(accounts, nowMs, opts) {
105
115
 
106
116
  const isCooldown = acc.cooldownUntil && Number(acc.cooldownUntil) > now && cooldownBlocks(acc, opts && opts.family)
107
117
  const inQuarantine = isQuarantined(acc, now)
108
- const exhausted = isQuotaExhausted(acc) || isUsageExhausted(acc)
118
+ const isHardExhausted = (acc.quota && acc.quota.remaining != null && Number(acc.quota.remaining) <= 0 && acc.quota.resetAt && Number(acc.quota.resetAt) > now) ||
119
+ (acc.usagePercent != null && Number(acc.usagePercent) >= 100 && (!acc.quota || !acc.quota.resetAt || Number(acc.quota.resetAt) > now))
120
+
121
+ if (isCooldown || inQuarantine || isHardExhausted) {
122
+ continue
123
+ }
109
124
 
110
- if (isCooldown || inQuarantine || exhausted) {
125
+ const softExhausted = isQuotaExhausted(acc) || isUsageExhausted(acc)
126
+ if (softExhausted) {
111
127
  tiers[2].push(acc)
112
- fallback.push(acc)
113
128
  continue
114
129
  }
115
130
 
@@ -140,7 +155,7 @@ export function pickAccount(accounts, nowMs, opts) {
140
155
  if (aWeight !== bWeight) return bWeight - aWeight
141
156
 
142
157
  // 3. Health Score
143
- return (b.healthScore || 100) - (a.healthScore || 100)
158
+ return (b.healthScore ?? 100) - (a.healthScore ?? 100)
144
159
  })
145
160
 
146
161
  const chosen = tier[0]
@@ -151,7 +166,6 @@ export function pickAccount(accounts, nowMs, opts) {
151
166
  }
152
167
  }
153
168
 
154
- if (fallback.length) return fallback[0]
155
169
  return null
156
170
  }
157
171
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.6.38",
3
+ "version": "0.6.39",
4
4
  "description": "Use ChatGPT Codex, Claude, Grok, Antigravity, Kimi, GLM, Cursor, Kiro, Copilot, Qwen, ERNIE, Spark, JetBrains, Perplexity, Replit, and Cody subscriptions as DeepSeek Harness LLM providers via OAuth.",
5
5
  "license": "MIT",
6
6
  "type": "module",