@goodandready/dsh-subscriptions 0.7.33 → 0.7.34

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
@@ -130,6 +130,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
130
130
  const notifyThresholds = new Map()
131
131
  const health = new Map()
132
132
  const requestCounts = new Map()
133
+ const lastUsedAt = new Map()
133
134
  const windows = new Map()
134
135
  const usageFetched = new Map()
135
136
  const pings = new Map()
@@ -278,6 +279,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
278
279
  // Counters and health must not outlive the account, otherwise a
279
280
  // re-added slot inherits stale rotation statistics.
280
281
  requestCounts.delete(ref)
282
+ lastUsedAt.delete(ref)
281
283
  health.delete(ref)
282
284
  windows.delete(ref)
283
285
  }
@@ -427,6 +429,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
427
429
  isDefault: !!slot.isDefault,
428
430
  ...(Array.isArray(slot.poolModels) ? { poolModels: slot.poolModels } : {}),
429
431
  credentialError: info.credentialError || null,
432
+ lastUsedAt: lastUsedAt.get(slot.ref) || 0,
430
433
  duplicateOf,
431
434
  })
432
435
  }
@@ -679,6 +682,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
679
682
  recordExhaust(ref) { health.set(ref, recordExhaust(health.get(ref))) },
680
683
  recordBroken(ref) { health.set(ref, recordBroken(health.get(ref))) },
681
684
  recordSuccess(ref) {
685
+ if (ref) lastUsedAt.set(ref, Date.now())
682
686
  // successful request clears the cooldown and puts the account back in rotation
683
687
  cooldowns.delete(ref)
684
688
  quarantines.delete(ref)
@@ -739,7 +743,10 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
739
743
  quarantines.delete(ref)
740
744
  saveQuarantinesToFile(quarantines)
741
745
  },
742
- rememberRequest(ref) { requestCounts.set(ref, (requestCounts.get(ref) || 0) + 1) },
746
+ rememberRequest(ref) {
747
+ requestCounts.set(ref, (requestCounts.get(ref) || 0) + 1)
748
+ if (ref) lastUsedAt.set(ref, Date.now())
749
+ },
743
750
  getRequestCount(ref) { return requestCounts.get(ref) || 0 },
744
751
  rememberPing(ref, { latencyMs, latencyGrade }) { pings.set(ref, { latencyMs, latencyGrade, at: Date.now() }) },
745
752
  getPing(ref) { return pings.get(ref) || null },
@@ -48,6 +48,9 @@ export function createAdapterManager({
48
48
  vendorConfig: (provider) => vendorConfig(provider, live()),
49
49
  cooldownMs: () => live().cooldownMs,
50
50
  autoPacing: () => live().autoPacing,
51
+ promptCacheTtlMs: () => Number(live().promptCacheTtlMinutes || 0) * 60 * 1000,
52
+ proactiveGuard: () => Boolean(live().proactiveGuard),
53
+ proactiveMinRemaining: () => Number(live().proactiveMinRemaining || 1),
51
54
  drainBeforeReset: () => live().drainBeforeReset,
52
55
  drainSafetyPercent: () => live().drainSafetyPercent,
53
56
  switchAtRemaining: () => live().switchAtRemaining,
@@ -273,6 +276,26 @@ export function createAdapterManager({
273
276
  }
274
277
  }
275
278
 
279
+ async function selfHealCoolingAccounts() {
280
+ const providers = await store.loggedInProviders()
281
+ for (const provider of providers) {
282
+ const accounts = await store.listAccounts(provider).catch(() => [])
283
+ for (const account of accounts) {
284
+ if (!account || !account.cooldownUntil || Number(account.cooldownUntil) <= Date.now()) continue
285
+ try {
286
+ const blob = await store.loadBlob(account.ref)
287
+ if (!blob) continue
288
+ const vendor = getVendor(provider)
289
+ const cfg = vendorConfig(provider, live())
290
+ if (typeof vendor.check === 'function') await vendor.check(blob, cfg, fetch)
291
+ else if (typeof vendor.listModels === 'function') await vendor.listModels(blob, cfg, fetch)
292
+ else continue
293
+ store.clearCooldown(account.ref)
294
+ } catch { /* keep cooling on probe failure */ }
295
+ }
296
+ }
297
+ }
298
+
276
299
  function registerAdapterLifecycle() {
277
300
  return ctx.effect(() => {
278
301
  syncAdapter().catch((err) => {
@@ -333,6 +356,9 @@ export function createAdapterManager({
333
356
  }
334
357
  eager()
335
358
 
359
+ // #820: plugin network calls use a per-address connect floor.
360
+ process.env.DSH_CONNECT_ATTEMPT_TIMEOUT_MS = String(Number(live().connectAttemptTimeoutMs) || 1500)
361
+
336
362
  // #799: refresh at startup and hourly. Per-ref snapshots younger than
337
363
  // five minutes are skipped, and failures keep last-known-good.
338
364
  refreshModels().catch((err) => {
@@ -345,10 +371,22 @@ export function createAdapterManager({
345
371
  }, 60 * 60 * 1000)
346
372
  if (modelTimer && typeof modelTimer.unref === 'function') modelTimer.unref()
347
373
 
374
+ let selfHealTimer
375
+ const selfHealMin = Number(live().selfHealIntervalMin) || 0
376
+ if (selfHealMin > 0) {
377
+ selfHealTimer = setInterval(() => {
378
+ selfHealCoolingAccounts().catch((err) => {
379
+ if (logger && logger.debug) logger.debug('[dsh-subscriptions] self-heal error: ' + (err && err.message || err))
380
+ })
381
+ }, selfHealMin * 60 * 1000)
382
+ if (typeof selfHealTimer.unref === 'function') selfHealTimer.unref()
383
+ }
384
+
348
385
  return () => {
349
386
  adapterDisposed = true
350
387
  if (retryTimer) clearTimeout(retryTimer)
351
388
  clearInterval(modelTimer)
389
+ if (selfHealTimer) clearInterval(selfHealTimer)
352
390
  if (handle) {
353
391
  disposeAdapterHandle(handle)
354
392
  handle = undefined
package/lib/adapter.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { resolveEffectiveReasoning, getModelReasoningCapabilities } from './reasoning-effort.js'
2
2
  import { inferModelCapabilities, isDeprecatedId, mergeAccountCatalogs, resolveCatalogSource } from './models-catalog.js'
3
+ import { replayStateKey, unwrapReplayState, wrapReplayState } from './routing-reliability.js'
3
4
  import { fetchWithTimeout } from "./http.js"
4
5
  import { LlmAdapter, LlmError, attributionHeaders } from '@deepseek-ai/dsh-llm'
5
6
  import { displayName } from './refs.js'
@@ -206,6 +207,9 @@ export class SubscriptionAdapter extends LlmAdapter {
206
207
  ...(!poolEnabled ? { onlyRef: defaultRef || '__no_default_account__' } : {}),
207
208
  ...(defaultRef ? { defaultRef } : {}),
208
209
  _decisionTrace: decisionTrace,
210
+ promptCacheTtlMs: typeof deps.promptCacheTtlMs === 'function' ? deps.promptCacheTtlMs() : 0,
211
+ proactiveGuard: typeof deps.proactiveGuard === 'function' ? deps.proactiveGuard() : false,
212
+ proactiveMinRemaining: typeof deps.proactiveMinRemaining === 'function' ? deps.proactiveMinRemaining() : 1,
209
213
  autoPacing: effectiveAutoPacing,
210
214
  drainBeforeReset: effectiveDrainBeforeReset,
211
215
  drainSafetyPercent: effectiveDrainSafetyPercent,
@@ -390,10 +394,22 @@ export class SubscriptionAdapter extends LlmAdapter {
390
394
 
391
395
  blob = await deps.ensureFresh(provider, await deps.loadBlob(account.ref), account.ref)
392
396
  if (typeof deps.rememberRequest === 'function') deps.rememberRequest(account.ref)
397
+ const replayKey = replayStateKey(provider, account.ref, opts && opts.model)
398
+ const safeOptions = {
399
+ ...opts,
400
+ messages: (Array.isArray(opts.messages) ? opts.messages : []).map((message) => {
401
+ if (!message || !message.replayState) return message
402
+ const replay = unwrapReplayState(message.replayState, replayKey)
403
+ if (replay.matched) return { ...message, replayState: replay.state }
404
+ const copy = { ...message }
405
+ delete copy.replayState
406
+ return copy
407
+ }),
408
+ }
393
409
  for await (const chunk of vendor.streamOnce({
394
410
  blob,
395
411
  accountId: account.id || account.ref,
396
- options: opts,
412
+ options: safeOptions,
397
413
  fetchImpl: quotaFetch,
398
414
  headers: attributionHeaders(),
399
415
  config: deps.vendorConfig(provider),
@@ -407,6 +423,7 @@ export class SubscriptionAdapter extends LlmAdapter {
407
423
  if (chunk.type === 'finish') {
408
424
  sawFinish = true
409
425
  finishReason = chunk.reason
426
+ if (chunk.replayState) chunk.replayState = wrapReplayState(chunk.replayState, replayKey)
410
427
  } else if (chunk.type === 'usage' && chunk.usage) {
411
428
  const u = toTokenUsage(chunk.usage)
412
429
  if (u && typeof u.outputTokens === 'number' && u.outputTokens > 0) {
package/lib/client.js CHANGED
@@ -80,7 +80,7 @@ window.__ModuleLoader__.load({
80
80
 
81
81
  const en = {
82
82
  "syncModels": "Sync Models",
83
- "syncModelsTitle": "Discover and synchronize available models from vendor API",
83
+ "syncModelsTitle": "Discover and synchronize available models from provider API",
84
84
  "modelsSynced": "Models synchronized",
85
85
  "recommendation": "Recommendation",
86
86
  "notConnected": "Not connected",
@@ -89,9 +89,9 @@ window.__ModuleLoader__.load({
89
89
  "usageFull": "Usage 100%",
90
90
  "connected": "Connected",
91
91
  "title": "Subscriptions",
92
- "intro": "Log in with a consumer subscription. Tokens stay on the host credentials store. The browser never reads them back. After Connect, if the provider lands on localhost or a vendor page, paste the redirected URL or the code here.",
92
+ "intro": "Log in with a consumer subscription. Tokens stay on the host credentials store. The browser never reads them back. After Connect, if the provider lands on localhost or a provider page, paste the redirected URL or the code here.",
93
93
  "useOrigin": "Use this Web UI origin as OAuth redirect_uri",
94
- "useOriginHint": "Leave off unless you registered your own OAuth client for this origin. Vendor CLI clients usually require their published redirect URI plus the paste step.",
94
+ "useOriginHint": "Leave off unless you registered your own OAuth client for this origin. Provider CLI clients usually require their published redirect URI plus the paste step.",
95
95
  "closeModal": "Close",
96
96
  "groupAuth": "Sign-in and redirect",
97
97
  "groupPrivacy": "Privacy and model list",
@@ -152,7 +152,7 @@ window.__ModuleLoader__.load({
152
152
  "smokeFail": "Smoke test failed",
153
153
  "testConnection": "Test",
154
154
  "testConnectionSuccess": "Connection verified",
155
- "cascadingFallback": "Cross-vendor fallback",
155
+ "cascadingFallback": "Cross-provider fallback",
156
156
  "cascadingFallbackHint": "Automatically route queries to alternative providers when all accounts of a primary provider are exhausted.",
157
157
  "cascadingFallbackOrder": "Fallback Provider Order",
158
158
  "cascadingFallbackOrderHint": "Sequence of alternative providers to try when primary accounts are exhausted.",
@@ -181,6 +181,16 @@ export const Config = z.object({
181
181
  .description('#510 In-app toast alerts for quarantine and quota threshold events.'),
182
182
  autoPacing: z.boolean().volatile().default(true)
183
183
  .description('#352 Predictive burn-rate load balancing between accounts.'),
184
+ promptCacheTtlMinutes: z.number().volatile().default(5)
185
+ .description('#810 Prompt-cache affinity TTL for session scoring. 0 disables cache affinity.'),
186
+ proactiveGuard: z.boolean().volatile().default(false)
187
+ .description('#792 Block an account before a request when its remaining quota is at the configured floor.'),
188
+ proactiveMinRemaining: z.number().volatile().default(1)
189
+ .description('#792 Remaining-quota floor used by the proactive guard.'),
190
+ selfHealIntervalMin: z.number().volatile().default(0)
191
+ .description('#792 Probe cooling accounts and release their cooldown on success. 0 disables.'),
192
+ connectAttemptTimeoutMs: z.number().volatile().default(1500)
193
+ .description('#820 Per-address connect timeout floor for plugin network calls; larger values are preserved.'),
184
194
  drainBeforeReset: z.boolean().volatile().default(false)
185
195
  .description('#485 Prioritize accounts with closest reset window to burn expiring quota first.'),
186
196
  drainSafetyPercent: z.number().volatile().default(5)
package/lib/http.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { timingSafeEqual } from 'node:crypto'
2
+ import { Agent } from 'undici'
3
+ import { effectiveConnectAttemptTimeout } from './routing-reliability.js'
2
4
 
3
5
  export function writeJson(res, code, body) {
4
6
  try {
@@ -341,6 +343,15 @@ export function isTimeoutError(err) {
341
343
  || String(err.message || '').toLowerCase().includes('operation was aborted')
342
344
  }
343
345
 
346
+ const connectDispatchers = new Map()
347
+ function connectDispatcher() {
348
+ const timeout = effectiveConnectAttemptTimeout(process.env.DSH_CONNECT_ATTEMPT_TIMEOUT_MS)
349
+ if (!connectDispatchers.has(timeout)) {
350
+ connectDispatchers.set(timeout, new Agent({ connect: { timeout } }))
351
+ }
352
+ return connectDispatchers.get(timeout)
353
+ }
354
+
344
355
  /**
345
356
  * @param {any} fetchImpl
346
357
  * @param {string} url
@@ -351,6 +362,7 @@ export async function fetchWithTimeout(fetchImpl, url, init = {}, { timeoutMs =
351
362
  const safeHeaders = sanitizeRequestHeaders(url, init, { allowedOrigins })
352
363
  init = { ...init, headers: safeHeaders }
353
364
  const impl = fetchImpl || fetch
365
+ if (!init.dispatcher && impl === globalThis.fetch) init.dispatcher = connectDispatcher()
354
366
  if (!timeoutMs || timeoutMs <= 0) return impl(url, init)
355
367
  const controller = new AbortController()
356
368
  let timer = setTimeout(() => {
package/lib/rotate.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { computePacingRisk, calculateBurnRatePerHour, calculateBurnUrgency } from "./forecast.js"
2
2
  import { isQuarantined } from './quarantine.js'
3
3
  import { getPinnedAccountRef, pinSession, touchSessionPin, unpinSession, isCacheAwareProvider } from './session-pin.js'
4
- import { accountCatalogHasModel, proactiveQuotaDecision } from './routing-reliability.js'
4
+ import { accountCatalogHasModel, proactiveQuotaDecision, promptCacheAffinity } from './routing-reliability.js'
5
5
 
6
6
  const SWITCH_CODES = new Set([
7
7
  'RATE_LIMIT',
@@ -390,6 +390,13 @@ export function pickAccountWithTrace(accounts, nowMs, opts) {
390
390
  if (Math.abs(aUrgency - bUrgency) > 0.01) return bUrgency - aUrgency
391
391
  }
392
392
 
393
+ // #810: keep session traffic on a warm prompt-cache account.
394
+ if (opts && Number(opts.promptCacheTtlMs) > 0) {
395
+ const aCache = promptCacheAffinity(a, now, opts.promptCacheTtlMs).score
396
+ const bCache = promptCacheAffinity(b, now, opts.promptCacheTtlMs).score
397
+ if (Math.abs(aCache - bCache) > 0.01) return bCache - aCache
398
+ }
399
+
393
400
  // 0. Auto-Pacing: lower pacing risk preferred (#352)
394
401
  const aRisk = Number(a.pacingRisk) || 0
395
402
  const bRisk = Number(b.pacingRisk) || 0
@@ -449,6 +456,7 @@ export function pickAccountWithTrace(accounts, nowMs, opts) {
449
456
  healthScore: chosen.healthScore ?? 100,
450
457
  urgency: calculateBurnUrgency(getAccountRemainingPercent(chosen), chosen.quota && chosen.quota.resetAt, now, opts && opts.drainSafetyPercent != null ? Number(opts.drainSafetyPercent) : 5),
451
458
  defaultAccount: Boolean(opts && opts.defaultRef && chosenRef === opts.defaultRef),
459
+ cacheAffinity: opts && Number(opts.promptCacheTtlMs) > 0 ? promptCacheAffinity(chosen, now, opts.promptCacheTtlMs) : null,
452
460
  },
453
461
  },
454
462
  }
@@ -54,6 +54,9 @@ export async function* streamWithRotation({
54
54
  model: options && options.model,
55
55
  onlyRef: options && options.onlyRef,
56
56
  defaultRef: options && options.defaultRef,
57
+ promptCacheTtlMs: options && options.promptCacheTtlMs,
58
+ proactiveGuard: options && options.proactiveGuard,
59
+ proactiveMinRemaining: options && options.proactiveMinRemaining,
57
60
  family: modelFamily(options && options.provider, options && options.model),
58
61
  sessionId: options && options.sessionId,
59
62
  tag: options && options.tag,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.7.33",
3
+ "version": "0.7.34",
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",