@goodandready/dsh-subscriptions 0.6.70 → 0.6.72

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.
@@ -1,3 +1,5 @@
1
+ import { diagnoseVendorError } from '../doctor.js'
2
+ import { INSTRUCTIONS } from '../instructions.js'
1
3
  import {
2
4
  getModelReasoningCapabilities,
3
5
  setSessionReasoning,
@@ -505,9 +507,13 @@ export function registerStatusRoutes(ctx, state) {
505
507
  blob = await store.loadBlob(ref)
506
508
  blob = await store.ensureFresh(provider, blob, ref)
507
509
  } catch (e) {
508
- const latencyMs = Date.now() - t0
510
+ const latencyMs = Math.max(1, Date.now() - t0)
511
+ const latencyGrade = latencyMs < 250 ? 'good' : (latencyMs < 750 ? 'fair' : 'poor')
512
+ if (typeof store.rememberPing === "function") store.rememberPing(ref, { latencyMs, latencyGrade })
513
+ const docVerdict = diagnoseVendorError(e, provider, blob)
514
+ if (typeof store.rememberDoctorVerdict === "function") store.rememberDoctorVerdict(ref, docVerdict)
509
515
  history.add({ provider, ref, path: "/smoke", status: 401, ms: latencyMs })
510
- writeJson(res, 502, { ok: false, provider, index, ref, latencyMs, error: { code: 'auth_failed', message: String(e && e.message || e) } })
516
+ writeJson(res, 502, { ok: false, provider, index, ref, latencyMs, latencyGrade, doctor: docVerdict, error: { code: 'auth_failed', message: String(e && e.message || e) } })
511
517
  return
512
518
  }
513
519
 
@@ -525,7 +531,10 @@ export function registerStatusRoutes(ctx, state) {
525
531
  }
526
532
  try {
527
533
  await vendor.check(blob, vendorConfig(provider, live()), probeFetch)
528
- const latencyMs = Date.now() - t0
534
+ const latencyMs = Math.max(1, Date.now() - t0)
535
+ const latencyGrade = latencyMs < 250 ? 'good' : (latencyMs < 750 ? 'fair' : 'poor')
536
+ if (typeof store.rememberPing === "function") store.rememberPing(ref, { latencyMs, latencyGrade })
537
+ if (typeof store.rememberDoctorVerdict === "function") store.rememberDoctorVerdict(ref, null)
529
538
  history.add({ provider, ref, path: "/smoke", status: 200, ms: latencyMs })
530
539
  writeJson(res, 200, {
531
540
  ok: true,
@@ -533,6 +542,7 @@ export function registerStatusRoutes(ctx, state) {
533
542
  index,
534
543
  ref,
535
544
  latencyMs,
545
+ latencyGrade,
536
546
  label: pmL(blob.label),
537
547
  email: pmE(blob.email),
538
548
  quota: capturedQuota || info.quota || null,
@@ -540,7 +550,11 @@ export function registerStatusRoutes(ctx, state) {
540
550
  })
541
551
  return
542
552
  } catch (e) {
543
- const latencyMs = Date.now() - t0
553
+ const latencyMs = Math.max(1, Date.now() - t0)
554
+ const latencyGrade = latencyMs < 250 ? 'good' : (latencyMs < 750 ? 'fair' : 'poor')
555
+ if (typeof store.rememberPing === "function") store.rememberPing(ref, { latencyMs, latencyGrade })
556
+ const docVerdict = diagnoseVendorError(e, provider, blob)
557
+ if (typeof store.rememberDoctorVerdict === "function") store.rememberDoctorVerdict(ref, docVerdict)
544
558
  history.add({ provider, ref, path: "/smoke", status: 502, ms: latencyMs })
545
559
  writeJson(res, 502, {
546
560
  ok: false,
@@ -548,13 +562,18 @@ export function registerStatusRoutes(ctx, state) {
548
562
  index,
549
563
  ref,
550
564
  latencyMs,
565
+ latencyGrade,
566
+ doctor: docVerdict,
551
567
  error: { code: e && e.code ? e.code : 'VENDOR', message: String(e && e.message || e).slice(0, 300) },
552
568
  })
553
569
  return
554
570
  }
555
571
  }
556
572
 
557
- const latencyMs = Date.now() - t0
573
+ const latencyMs = Math.max(1, Date.now() - t0)
574
+ const latencyGrade = latencyMs < 250 ? 'good' : (latencyMs < 750 ? 'fair' : 'poor')
575
+ if (typeof store.rememberPing === "function") store.rememberPing(ref, { latencyMs, latencyGrade })
576
+ if (typeof store.rememberDoctorVerdict === "function") store.rememberDoctorVerdict(ref, null)
558
577
  history.add({ provider, ref, path: "/smoke", status: 200, ms: latencyMs })
559
578
  writeJson(res, 200, {
560
579
  ok: true,
@@ -562,6 +581,7 @@ export function registerStatusRoutes(ctx, state) {
562
581
  index,
563
582
  ref,
564
583
  latencyMs,
584
+ latencyGrade,
565
585
  label: pmL(blob.label),
566
586
  email: pmE(blob.email),
567
587
  quota: info.quota || null,
@@ -569,6 +589,80 @@ export function registerStatusRoutes(ctx, state) {
569
589
  }),
570
590
  }), 'dsh-subscriptions: /smoke')
571
591
 
592
+ // Issue #542: Latency RTT ping test endpoint
593
+ ctx.effect(() => ctx.webServer.register({
594
+ kind: 'exact',
595
+ path: '/dsh-subscriptions/ping',
596
+ handler: safeJsonHandler(async (req, res) => {
597
+ if (req.method !== 'POST') {
598
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
599
+ return
600
+ }
601
+ if (!isTrustedSettingsRequest(req, ctx)) {
602
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
603
+ return
604
+ }
605
+ let payload = {}
606
+ try { payload = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}') } catch { /* ignore ping payload */ }
607
+ const provider = payload.provider
608
+ const index = payload.index || 1
609
+ if (!provider || !isProvider(provider)) {
610
+ writeJson(res, 400, { ok: false, error: { code: 'invalid_provider', message: 'Provider required' } })
611
+ return
612
+ }
613
+ const ref = oauthRef(provider, index)
614
+ const info = await store.describeRef(ref)
615
+ if (!info.configured) {
616
+ writeJson(res, 400, { ok: false, error: { code: 'not_configured', message: 'Account is not configured' } })
617
+ return
618
+ }
619
+ const t0 = Date.now()
620
+ let latencyMs = 0
621
+ let latencyGrade = 'good'
622
+ try {
623
+ const blob = await store.loadBlob(ref)
624
+ const vendor = getVendor(provider)
625
+ const cfg = vendorConfig(provider, live())
626
+ const doFetch = (typeof fetchForRef === 'function' && fetchForRef(ref)) || fetch
627
+ if (typeof vendor.check === 'function') {
628
+ await vendor.check(blob, cfg, doFetch)
629
+ } else if (typeof vendor.listModels === 'function') {
630
+ await vendor.listModels(blob, cfg, doFetch)
631
+ }
632
+ latencyMs = Math.max(1, Date.now() - t0)
633
+ latencyGrade = latencyMs < 250 ? 'good' : (latencyMs < 750 ? 'fair' : 'poor')
634
+ if (typeof store.rememberPing === 'function') store.rememberPing(ref, { latencyMs, latencyGrade })
635
+ if (typeof store.rememberDoctorVerdict === 'function') store.rememberDoctorVerdict(ref, null)
636
+ writeJson(res, 200, { ok: true, provider, index, ref, latencyMs, latencyGrade })
637
+ } catch (err) {
638
+ latencyMs = Math.max(1, Date.now() - t0)
639
+ latencyGrade = 'poor'
640
+ const doc = diagnoseVendorError(err, provider)
641
+ if (typeof store.rememberPing === 'function') store.rememberPing(ref, { latencyMs, latencyGrade })
642
+ if (typeof store.rememberDoctorVerdict === 'function') store.rememberDoctorVerdict(ref, doc)
643
+ writeJson(res, 502, { ok: false, provider, index, ref, latencyMs, latencyGrade, doctor: doc, error: { message: String(err && err.message || err) } })
644
+ }
645
+ }),
646
+ }), 'dsh-subscriptions: /ping')
647
+
648
+ // Issue #540: Dynamic vendor instructions API
649
+ ctx.effect(() => ctx.webServer.register({
650
+ kind: 'exact',
651
+ path: '/dsh-subscriptions/instructions',
652
+ handler: safeJsonHandler(async (req, res) => {
653
+ if (req.method !== 'GET') {
654
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
655
+ return
656
+ }
657
+ if (!isTrustedSettingsRequest(req, ctx)) {
658
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
659
+ return
660
+ }
661
+ writeJson(res, 200, { ok: true, instructions: INSTRUCTIONS })
662
+ }),
663
+ }), 'dsh-subscriptions: /instructions')
664
+
665
+
572
666
  // #351: Quota and session alerts API
573
667
  ctx.effect(() => ctx.webServer.register({
574
668
  kind: 'exact',
@@ -0,0 +1,136 @@
1
+ export const SOFT_FAILURE_PATTERNS = [
2
+ /currently (?:unable to process|unavailable|overloaded)/i,
3
+ /experiencing (?:exceptionally )?(?:high|heavy) (?:traffic|demand|load)/i,
4
+ /capacity (?:is )?temporarily (?:unavailable|limited|exceeded)/i,
5
+ /model is (?:currently )?(?:overloaded|busy)/i,
6
+ /due to high traffic/i,
7
+ /usage cap reached, switching to lower/i,
8
+ /you have reached your (?:current )?usage limit/i,
9
+ /server is currently under heavy load/i,
10
+ /i apologize, but (?:i am unable to assist|i cannot fulfill this request due to (?:high demand|capacity|server load))/i,
11
+ /our systems are experiencing heavy load/i,
12
+ ]
13
+
14
+ const SUSPICIOUS_ROOTS = [
15
+ 'i am',
16
+ 'i apologize',
17
+ 'we are',
18
+ 'capacity',
19
+ 'the model',
20
+ 'due to',
21
+ 'usage cap',
22
+ 'you have reached',
23
+ 'server is',
24
+ 'our systems',
25
+ ]
26
+
27
+ export function extractChunkText(chunk) {
28
+ if (!chunk) return ''
29
+ if (typeof chunk === 'string') return chunk
30
+ if (typeof chunk.delta === 'string') return chunk.delta
31
+ if (typeof chunk.content === 'string') return chunk.content
32
+ if (typeof chunk.text === 'string') return chunk.text
33
+ if (chunk.choices && Array.isArray(chunk.choices) && chunk.choices[0]) {
34
+ const c0 = chunk.choices[0]
35
+ if (c0.delta && typeof c0.delta.content === 'string') return c0.delta.content
36
+ if (typeof c0.text === 'string') return c0.text
37
+ }
38
+ if (chunk.candidates && Array.isArray(chunk.candidates) && chunk.candidates[0]) {
39
+ const cand = chunk.candidates[0]
40
+ if (cand.content && Array.isArray(cand.content.parts)) {
41
+ return cand.content.parts.map((p) => (p && typeof p.text === 'string' ? p.text : '')).join('')
42
+ }
43
+ }
44
+ return ''
45
+ }
46
+
47
+ export function detectSoftFailure(text) {
48
+ if (!text || typeof text !== 'string') return null
49
+ const cleaned = text.trim()
50
+ if (!cleaned) return null
51
+ for (const pattern of SOFT_FAILURE_PATTERNS) {
52
+ if (pattern.test(cleaned)) {
53
+ return pattern.toString()
54
+ }
55
+ }
56
+ return null
57
+ }
58
+
59
+ export function isCandidatePrefix(text) {
60
+ if (!text) return true
61
+ const low = text.toLowerCase().trim()
62
+ if (low.length < 3) return true
63
+ for (const root of SUSPICIOUS_ROOTS) {
64
+ if (root.startsWith(low) || low.startsWith(root)) {
65
+ return true
66
+ }
67
+ }
68
+ return false
69
+ }
70
+
71
+ export function createSoftFailureError(pattern, text) {
72
+ const preview = String(text || '').slice(0, 120).replace(/\s+/g, ' ')
73
+ const err = new Error(`Soft-failure detected: ${preview}`)
74
+ return Object.assign(err, {
75
+ name: 'SoftFailureError',
76
+ code: 'SOFT_FAILURE',
77
+ status: 429,
78
+ isSoftFailure: true,
79
+ degraded: true,
80
+ pattern,
81
+ })
82
+ }
83
+
84
+ export async function* withSoftFailureSentinel(stream, options = {}) {
85
+ const enabled = options.enabled !== false
86
+ if (!enabled || !stream) {
87
+ if (stream) {
88
+ for await (const chunk of stream) {
89
+ yield chunk
90
+ }
91
+ }
92
+ return
93
+ }
94
+
95
+ const bufferLimitChars = Number(options.bufferLimitChars) || 250
96
+ const maxBufferChunks = Number(options.maxBufferChunks) || 4
97
+
98
+ const bufferedChunks = []
99
+ let accumulatedText = ''
100
+ let flushed = false
101
+
102
+ for await (const chunk of stream) {
103
+ if (flushed) {
104
+ yield chunk
105
+ continue
106
+ }
107
+
108
+ bufferedChunks.push(chunk)
109
+ accumulatedText += extractChunkText(chunk)
110
+
111
+ const matched = detectSoftFailure(accumulatedText)
112
+ if (matched) {
113
+ throw createSoftFailureError(matched, accumulatedText)
114
+ }
115
+
116
+ const candidate = isCandidatePrefix(accumulatedText)
117
+ if (!candidate || accumulatedText.length >= bufferLimitChars || bufferedChunks.length >= maxBufferChunks) {
118
+ flushed = true
119
+ for (const b of bufferedChunks) {
120
+ yield b
121
+ }
122
+ bufferedChunks.length = 0
123
+ }
124
+ }
125
+
126
+ if (!flushed) {
127
+ const matched = detectSoftFailure(accumulatedText)
128
+ if (matched) {
129
+ throw createSoftFailureError(matched, accumulatedText)
130
+ }
131
+ for (const b of bufferedChunks) {
132
+ yield b
133
+ }
134
+ bufferedChunks.length = 0
135
+ }
136
+ }
@@ -1,10 +1,12 @@
1
1
  import { defaultConcurrencyQueue } from './concurrency-queue.js'
2
2
  import { recordSessionCacheUsage } from './session-pin.js'
3
3
  import { pickAccountWithTrace, markCooldown, isSwitchableError, isRegionError, modelFamily } from './rotate.js'
4
- import { putInQuarantine, REASON_RATE_LIMIT, REASON_HARD_LIMIT, REASON_REVOKED } from './quarantine.js'
4
+ import { putInQuarantine, REASON_RATE_LIMIT, REASON_HARD_LIMIT, REASON_REVOKED, REASON_DEGRADED } from './quarantine.js'
5
5
  import { notifySessionExpired, notifyQuarantineEntered } from './alerts.js'
6
6
  import { withRunawayGuard } from './runaway-guard.js'
7
7
  import { defaultErrorBuffer } from './error-buffer.js'
8
+ import { withSoftFailureSentinel } from './sentinel.js'
9
+ import { resolveAccurateReset } from './reset-clock.js'
8
10
 
9
11
  export async function* streamWithRotation({
10
12
  accounts,
@@ -55,6 +57,7 @@ export async function* streamWithRotation({
55
57
  autoPacing: options && (options.autoPacing !== undefined ? options.autoPacing : (options.config && options.config.autoPacing)),
56
58
  drainBeforeReset: options && (options.drainBeforeReset !== undefined ? options.drainBeforeReset : (options.config && options.config.drainBeforeReset)),
57
59
  drainSafetyPercent: options && (options.drainSafetyPercent !== undefined ? options.drainSafetyPercent : (options.config && options.config.drainSafetyPercent)),
60
+ rotationStrategy: options && (options.rotationStrategy !== undefined ? options.rotationStrategy : (options.config && options.config.rotationStrategy)),
58
61
  })
59
62
  const account = pickRes.account
60
63
 
@@ -147,7 +150,10 @@ export async function* streamWithRotation({
147
150
  streamOnce(account, { ...options, _runawayState: runawayState }),
148
151
  runawayOpts
149
152
  )
150
- for await (const chunk of guardedStream) {
153
+ const sentineledStream = withSoftFailureSentinel(guardedStream, {
154
+ enabled: (options?.enableSoftFailureSentinel !== false) && (options?.config?.enableSoftFailureSentinel !== false),
155
+ })
156
+ for await (const chunk of sentineledStream) {
151
157
  firstChunkDelivered = true
152
158
  const cachedTokens = Number(
153
159
  (chunk && chunk.usage && (chunk.usage.cacheReadTokens || chunk.usage.cachedTokens)) ||
@@ -195,18 +201,24 @@ export async function* streamWithRotation({
195
201
  currentAttempt.status = 'switched'
196
202
  currentAttempt.switchError = err ? (err.code || err.status || err.message) : 'switchable'
197
203
 
198
- // Move slot to cooldown and quarantine (#172)
199
- const cooled = markCooldown(account, nowMs(), cooldownMs, modelFamily(options && options.provider, options && options.model))
204
+ const accurateUntil = resolveAccurateReset(err, account, options && options.provider, nowMs())
205
+ const isSoft = Boolean(err && (err.code === 'SOFT_FAILURE' || err.isSoftFailure))
206
+
207
+ // Move slot to cooldown and quarantine (#172, #538)
208
+ const effectiveCooldownMs = isSoft ? Math.min(cooldownMs, 5 * 60 * 1000) : cooldownMs
209
+ const cooled = markCooldown(account, nowMs(), effectiveCooldownMs, modelFamily(options && options.provider, options && options.model), accurateUntil)
200
210
  account.cooldownUntil = cooled.cooldownUntil
201
211
  if (cooled.cooldownFamilies) account.cooldownFamilies = cooled.cooldownFamilies
202
212
 
203
213
  const status = Number(err && (err.status || err.statusCode) || 0)
204
214
  const code = String(err && err.code || '')
205
- const reason = (status === 401 || code === 'AUTH' || code === 'TOKEN_REVOKED')
206
- ? REASON_REVOKED
207
- : (status === 403 || code === 'HARD_LIMIT' || code === 'LICENSE_REQUIRED')
208
- ? REASON_HARD_LIMIT
209
- : REASON_RATE_LIMIT
215
+ const reason = isSoft
216
+ ? REASON_DEGRADED
217
+ : (status === 401 || code === 'AUTH' || code === 'TOKEN_REVOKED')
218
+ ? REASON_REVOKED
219
+ : (status === 403 || code === 'HARD_LIMIT' || code === 'LICENSE_REQUIRED')
220
+ ? REASON_HARD_LIMIT
221
+ : REASON_RATE_LIMIT
210
222
 
211
223
  if (err && err.isWarmupProbeFailure && err.warmupAccount) {
212
224
  account.quarantineUntil = err.warmupAccount.quarantineUntil
@@ -214,7 +226,7 @@ export async function* streamWithRotation({
214
226
  account.quarantineAttempts = err.warmupAccount.quarantineAttempts
215
227
  account.status = err.warmupAccount.status
216
228
  } else {
217
- const quarantined = putInQuarantine(account, reason, nowMs())
229
+ const quarantined = putInQuarantine(account, reason, nowMs(), accurateUntil)
218
230
  account.quarantineUntil = quarantined.quarantineUntil
219
231
  account.quarantineReason = quarantined.quarantineReason
220
232
  account.quarantineAttempts = (account.quarantineAttempts || 0) + 1
@@ -222,6 +234,10 @@ export async function* streamWithRotation({
222
234
 
223
235
  currentAttempt.quarantineReason = account.quarantineReason || reason
224
236
  currentAttempt.quarantineUntil = account.quarantineUntil
237
+ if (accurateUntil) {
238
+ currentAttempt.accurateReset = true
239
+ currentAttempt.resetAt = accurateUntil
240
+ }
225
241
 
226
242
  try {
227
243
  notifyQuarantineEntered({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.6.70",
3
+ "version": "0.6.72",
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",