@goodandready/dsh-subscriptions 0.7.31 → 0.7.33

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.
@@ -0,0 +1,68 @@
1
+ function text(value) {
2
+ return value == null ? '' : String(value).trim()
3
+ }
4
+
5
+ /**
6
+ * #827: decide whether a stored credential is usable without exposing it.
7
+ * Empty access/refresh tokens and non-finite expiry are treated as corrupt.
8
+ */
9
+ export function credentialState(blob) {
10
+ if (!blob || typeof blob !== 'object') return { usable: false, reason: 'invalid_blob' }
11
+ const accessToken = text(blob.accessToken)
12
+ const refreshToken = text(blob.refreshToken)
13
+ if (!accessToken && !refreshToken && !text(blob.apiKey)) {
14
+ return { usable: false, reason: 'empty_credentials' }
15
+ }
16
+ if (blob.expiresAt != null && blob.expiresAt !== '' && !Number.isFinite(Number(blob.expiresAt))) {
17
+ return { usable: false, reason: 'invalid_expiry' }
18
+ }
19
+ return { usable: true, reason: null }
20
+ }
21
+
22
+ /**
23
+ * #831: stable provider identity used for duplicate detection. Unknown
24
+ * identities return null and must never be merged automatically.
25
+ */
26
+ export function accountIdentityKey(provider, blob) {
27
+ if (!blob || typeof blob !== 'object') return null
28
+ const id = text(blob.email || blob.login || blob.accountId || blob.userId)
29
+ if (!id) return null
30
+ return `${text(provider).toLowerCase()}:${id.toLowerCase()}`
31
+ }
32
+
33
+ /**
34
+ * #818: aliases are display-only. Resolve either a stable ref or a legacy
35
+ * label within one provider back to the stable credential ref.
36
+ */
37
+ export function resolveAccountRef(slots, provider, value) {
38
+ const wanted = text(value)
39
+ if (!wanted) return null
40
+ const list = (Array.isArray(slots) ? slots : []).filter((slot) => slot && slot.provider === provider)
41
+ const byIndex = Number(wanted)
42
+ if (Number.isInteger(byIndex) && byIndex > 0) {
43
+ return list.find((slot) => Number(slot.index) === byIndex)?.ref || null
44
+ }
45
+ return list.find((slot) => slot.ref === wanted || text(slot.label) === wanted)?.ref || null
46
+ }
47
+
48
+ /**
49
+ * #806: a delayed refresh may only write when its credential lineage is
50
+ * still current. A newer save/revoke bumps the version.
51
+ */
52
+ export function refreshIsCurrent(currentVersion, expectedVersion) {
53
+ return Number(currentVersion) === Number(expectedVersion)
54
+ }
55
+
56
+ /**
57
+ * #833: deterministic host-env vs plugin-proxy precedence.
58
+ * @param {{ hostProxy?: string, pluginProxy?: string, prefer?: 'host' | 'plugin' }} [options]
59
+ */
60
+ export function proxyPrecedence(options = {}) {
61
+ const prefer = options.prefer || 'host'
62
+ const host = text(options.hostProxy)
63
+ const plugin = text(options.pluginProxy)
64
+ if (host && plugin && prefer === 'plugin') return { proxy: plugin, source: 'plugin', conflict: true }
65
+ if (host) return { proxy: host, source: 'host', conflict: Boolean(plugin) }
66
+ if (plugin) return { proxy: plugin, source: 'plugin', conflict: false }
67
+ return { proxy: '', source: 'none', conflict: false }
68
+ }
@@ -169,6 +169,8 @@ export async function createAccountsView({ live, store, fetch, pmL, pmE, concurr
169
169
  poolEnabled: slot.poolEnabled !== false,
170
170
  isDefault: !!slot.isDefault,
171
171
  ...(Array.isArray(slot.poolModels) ? { poolModels: slot.poolModels } : {}),
172
+ credentialError: info.credentialError || null,
173
+ duplicateOf: info.duplicateOf || null,
172
174
  capabilities,
173
175
  billing,
174
176
  renewsAt: billing.renewsAt,
package/lib/accounts.js CHANGED
@@ -46,6 +46,7 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials'
46
46
  import { oauthRef, isProvider } from './refs.js'
47
47
  import { parseBlob, serializeBlob } from './blob.js'
48
48
  import { encryptWithPassphrase, decryptWithPassphrase } from './crypto.js'
49
+ import { accountIdentityKey, credentialState } from './account-reliability.js'
49
50
  import { recordSwitch, recordExhaust, recordBroken, computeHealthScore, healthBadge } from './health.js'
50
51
  import { executeWithRetry } from './backoff.js'
51
52
  import { getVendor } from './vendors/index.js'
@@ -308,6 +309,26 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
308
309
  if (base.configured) {
309
310
  try {
310
311
  const blob = parseBlob(await resolveRaw(ref))
312
+ const state = credentialState(blob)
313
+ if (!state.usable) {
314
+ base.configured = false
315
+ base.credentialError = state.reason
316
+ base.label = blob.label || blob.email || ''
317
+ return {
318
+ ...base,
319
+ cooldownUntil: 0,
320
+ quarantineUntil: 0,
321
+ cooldownFamilies: null,
322
+ cooldownModels: null,
323
+ quota: null,
324
+ isRevoked: false,
325
+ authWarning: '',
326
+ validationUrl: '',
327
+ validationMessage: '',
328
+ accountNotice: '',
329
+ paidTierName: '',
330
+ }
331
+ }
311
332
  base.label = blob.label || blob.email || ''
312
333
  base.email = blob.email || ''
313
334
  base.plan = blob.plan || blob.paidTierName || ''
@@ -367,6 +388,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
367
388
  latencyGrade: pings.get(ref)?.latencyGrade ?? null,
368
389
  lastDoctorVerdict: doctorVerdicts.get(ref) || null,
369
390
  availableModels: dynamicModels.get(ref)?.models || null,
391
+ credentialError: base.credentialError || null,
370
392
  }
371
393
  }
372
394
 
@@ -374,8 +396,12 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
374
396
  const curConfig = getConfig()
375
397
  const slots = normalizeSlots(curConfig.slots).filter((s) => s.provider === provider)
376
398
  const out = []
399
+ const identities = new Map()
377
400
  for (const slot of slots) {
378
401
  const info = await describeRef(slot.ref, curConfig)
402
+ const identity = accountIdentityKey(provider, { email: info.email, accountId: info.accountId })
403
+ const duplicateOf = identity ? (identities.get(identity) || null) : null
404
+ if (identity && !duplicateOf) identities.set(identity, slot.ref)
379
405
  const q = quarantines.get(slot.ref)
380
406
  out.push({
381
407
  ref: slot.ref,
@@ -400,6 +426,8 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
400
426
  poolEnabled: slot.poolEnabled !== false,
401
427
  isDefault: !!slot.isDefault,
402
428
  ...(Array.isArray(slot.poolModels) ? { poolModels: slot.poolModels } : {}),
429
+ credentialError: info.credentialError || null,
430
+ duplicateOf,
403
431
  })
404
432
  }
405
433
  return out
package/lib/index.js CHANGED
@@ -18,6 +18,7 @@ import { createSubscriptionsService } from './subscriptions.js'
18
18
  import { registerSubscriptionTools } from './native-tools.js'
19
19
  import { detectConflicts, readProfilePluginNames, setActiveConflicts } from './coexistence.js'
20
20
  import { createAccountStore, defaultAccountRef, normalizeSlots, vendorConfig } from './accounts.js'
21
+ import { resolveAccountRef } from './account-reliability.js'
21
22
  import { OllamaAdapter, ollamaAlive, ollamaModels, ollamaBase } from './ollama.js'
22
23
  import { createResetCreditService } from './reset-credits.js'
23
24
  import { maskEmail, maskLabel } from './mask.js'
@@ -276,9 +277,8 @@ export function apply(ctx, rawConfig) {
276
277
 
277
278
  // #85: host-only reset credit service for Codex accounts.
278
279
  const resetCredits = createResetCreditService({ loadBlob: (ref) => store.loadBlob(ref) })
279
- function refForSlot(provider, index) {
280
- const slot = normalizeSlots(live().slots).find((s) => s.provider === provider && s.index === index)
281
- return slot ? slot.ref : null
280
+ function refForSlot(provider, indexOrAlias) {
281
+ return resolveAccountRef(normalizeSlots(live().slots), provider, indexOrAlias)
282
282
  }
283
283
 
284
284
  function stripLegacySlots(slots) {
package/lib/rotate.js CHANGED
@@ -1,6 +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
5
 
5
6
  const SWITCH_CODES = new Set([
6
7
  'RATE_LIMIT',
@@ -159,7 +160,9 @@ export function pickAccountWithTrace(accounts, nowMs, opts) {
159
160
  const pinnedRef = getPinnedAccountRef(targetSession, now)
160
161
  if (pinnedRef) {
161
162
  const pinned = list.find((a) => (a.ref || a.id) === pinnedRef)
162
- const pinnedBlock = pinned ? poolBlockReason(pinned, opts) : null
163
+ const pinnedBlock = pinned
164
+ ? (poolBlockReason(pinned, opts) || (!accountCatalogHasModel(pinned, opts && opts.model) ? 'model_not_in_account_catalog' : null))
165
+ : null
163
166
  if (pinned && pinnedBlock) {
164
167
  unpinSession(targetSession, pinnedBlock)
165
168
  evaluations.push({
@@ -248,6 +251,17 @@ export function pickAccountWithTrace(accounts, nowMs, opts) {
248
251
  evaluations.push({ ref, eligible: false, tier: null, reason: blockedByPool })
249
252
  continue
250
253
  }
254
+ if (!accountCatalogHasModel(acc, opts && opts.model)) {
255
+ evaluations.push({ ref, eligible: false, tier: null, reason: 'model_not_in_account_catalog', model: opts && opts.model })
256
+ continue
257
+ }
258
+ if (opts && opts.proactiveGuard === true) {
259
+ const proactive = proactiveQuotaDecision(acc.quota, now, opts.proactiveMinRemaining)
260
+ if (proactive.block) {
261
+ evaluations.push({ ref, eligible: false, tier: null, reason: proactive.reason, until: proactive.until })
262
+ continue
263
+ }
264
+ }
251
265
 
252
266
  if (!opts || opts.autoPacing !== false) {
253
267
  if (acc.pacingRisk === undefined) {
@@ -0,0 +1,71 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ export const DEFAULT_PROMPT_CACHE_TTL_MS = 5 * 60 * 1000
4
+ export const MIN_CONNECT_ATTEMPT_TIMEOUT_MS = 1500
5
+
6
+ function text(value) {
7
+ return value == null ? '' : String(value).trim()
8
+ }
9
+
10
+ /** #810: cache usefulness score for an account (higher = warmer cache). */
11
+ export function promptCacheAffinity(account, nowMs = Date.now(), ttlMs = DEFAULT_PROMPT_CACHE_TTL_MS) {
12
+ if (!account) return { score: 0, state: 'cache_expired' }
13
+ const at = Number(account.lastUsedAt || account.cacheAt || 0)
14
+ if (!at) return { score: 0, state: 'cache_expired' }
15
+ const ttl = Number(ttlMs) > 0 ? Number(ttlMs) : 0
16
+ if (!ttl) return { score: 0, state: 'cache_off' }
17
+ const age = Math.max(0, Number(nowMs) - at)
18
+ if (age >= ttl) return { score: 0, state: 'cache_expired', ageMs: age }
19
+ return { score: 1 - age / ttl, state: 'cache_affinity', ageMs: age }
20
+ }
21
+
22
+ /** #813: a model may only route to accounts whose catalog contains it. */
23
+ export function accountCatalogHasModel(account, model) {
24
+ if (!Array.isArray(account?.availableModels) || !account.availableModels.length) return true
25
+ const wanted = text(model).toLowerCase()
26
+ if (!wanted) return true
27
+ return account.availableModels.some((entry) => {
28
+ const id = typeof entry === 'string' ? entry : (entry && (entry.id || entry.slug || entry.name))
29
+ return text(id).toLowerCase() === wanted
30
+ })
31
+ }
32
+
33
+ /** #820: never lower a larger host value; raise tiny per-address attempts. */
34
+ export function effectiveConnectAttemptTimeout(hostValue, minimum = MIN_CONNECT_ATTEMPT_TIMEOUT_MS) {
35
+ const host = Number(hostValue)
36
+ const min = Number(minimum) > 0 ? Number(minimum) : MIN_CONNECT_ATTEMPT_TIMEOUT_MS
37
+ if (!Number.isFinite(host) || host <= 0) return min
38
+ return Math.max(host, min)
39
+ }
40
+
41
+ /** #824: bind opaque provider replay state to provider/account/model. */
42
+ export function replayStateKey(provider, accountRef, model) {
43
+ return createHash('sha256')
44
+ .update(`${text(provider).toLowerCase()}|${text(accountRef)}|${text(model)}`)
45
+ .digest('hex')
46
+ .slice(0, 24)
47
+ }
48
+
49
+ export function wrapReplayState(state, key, version = 1) {
50
+ if (state == null || !key) return state
51
+ return { kind: 'dsh-subscriptions.replay', version, key, state }
52
+ }
53
+
54
+ export function unwrapReplayState(envelope, key) {
55
+ if (!envelope || typeof envelope !== 'object' || envelope.kind !== 'dsh-subscriptions.replay') {
56
+ return { state: envelope, legacy: true, matched: true }
57
+ }
58
+ return { state: envelope.key === key ? envelope.state : undefined, legacy: false, matched: envelope.key === key }
59
+ }
60
+
61
+ /** #792: proactive guard for near-zero remaining quota. */
62
+ export function proactiveQuotaDecision(quota, nowMs = Date.now(), minRemaining = 1) {
63
+ if (!quota) return { block: false, reason: null }
64
+ const resetAt = Number(quota.resetAt || 0)
65
+ if (resetAt && resetAt <= nowMs) return { block: false, reason: null }
66
+ const remaining = quota.remaining != null ? Number(quota.remaining) : null
67
+ const remainingPercent = quota.remainingPercent != null ? Number(quota.remainingPercent) : null
68
+ if (remaining != null && remaining <= Number(minRemaining)) return { block: true, reason: 'remaining_low', until: resetAt || null }
69
+ if (remainingPercent != null && remainingPercent <= 0) return { block: true, reason: 'remaining_zero', until: resetAt || null }
70
+ return { block: false, reason: null }
71
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.7.31",
3
+ "version": "0.7.33",
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",