@goodandready/dsh-subscriptions 0.7.38 → 0.7.40

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.
@@ -55,6 +55,7 @@ export function createAdapterManager({
55
55
  drainSafetyPercent: () => live().drainSafetyPercent,
56
56
  switchAtRemaining: () => live().switchAtRemaining,
57
57
  rotationStrategy: () => live().rotationStrategy,
58
+ modelAccountRules: () => live().modelAccountRules || [],
58
59
  rateLimitWait: () => live().rateLimitWait,
59
60
  rateLimitMaxWaitMs: () => live().rateLimitMaxWaitMs,
60
61
  enableSoftFailureSentinel: () => live().enableSoftFailureSentinel,
package/lib/adapter.js CHANGED
@@ -221,6 +221,8 @@ export class SubscriptionAdapter extends LlmAdapter {
221
221
  drainBeforeReset: effectiveDrainBeforeReset,
222
222
  drainSafetyPercent: effectiveDrainSafetyPercent,
223
223
  rotationStrategy: effectiveRotationStrategy,
224
+ modelAccountRules: typeof deps.modelAccountRules === 'function' ? deps.modelAccountRules() : [],
225
+ provider,
224
226
  enableSoftFailureSentinel: effectiveSoftFailureSentinel,
225
227
  rateLimitWait: typeof deps.rateLimitWait === 'function' ? deps.rateLimitWait() : deps.rateLimitWait,
226
228
  rateLimitMaxWaitMs: typeof deps.rateLimitMaxWaitMs === 'function' ? deps.rateLimitMaxWaitMs() : deps.rateLimitMaxWaitMs,
@@ -0,0 +1,35 @@
1
+ /** #798: choose a client version with deterministic source precedence. */
2
+ export function resolveClientVersion({ configValue, npmVersion, localVersion, fallback } = {}) {
3
+ const values = [
4
+ ['config', configValue],
5
+ ['npm', npmVersion],
6
+ ['local', localVersion],
7
+ ['fallback', fallback],
8
+ ]
9
+ for (const [source, value] of values) {
10
+ const text = String(value || '').trim()
11
+ if (text) return { version: text, source }
12
+ }
13
+ return { version: '', source: 'none' }
14
+ }
15
+
16
+ export function parseNpmVersion(json) {
17
+ if (!json || typeof json !== 'object') return ''
18
+ return String(json.version || '').trim()
19
+ }
20
+
21
+ export function createVersionCache(ttlMs = 6 * 60 * 60 * 1000) {
22
+ const cache = new Map()
23
+ return {
24
+ get(key, now = Date.now()) {
25
+ const hit = cache.get(key)
26
+ if (!hit) return null
27
+ if (Number(now) - hit.at >= ttlMs) return null
28
+ return hit.value
29
+ },
30
+ set(key, value, now = Date.now()) {
31
+ cache.set(key, { at: Number(now), value })
32
+ },
33
+ clear() { cache.clear() },
34
+ }
35
+ }
package/lib/compat.js ADDED
@@ -0,0 +1,20 @@
1
+ /** #816: normalize host API shape differences in one place. */
2
+ export function toolCallId(value) {
3
+ if (!value || typeof value !== 'object') return ''
4
+ return String(value.toolCallId || value.tool_call_id || value.callId || value.call_id || value.id || '')
5
+ }
6
+
7
+ export function normalizeRpcResult(value) {
8
+ if (value == null) return { ok: false, value: null }
9
+ if (typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, 'ok')) {
10
+ return { ok: Boolean(value.ok), value: value.value ?? value.result ?? null }
11
+ }
12
+ return { ok: true, value }
13
+ }
14
+
15
+ export function hostCompatibilityError(detail) {
16
+ const suffix = detail ? `: ${String(detail)}` : ''
17
+ const err = new Error(`unsupported DSH host API${suffix}`)
18
+ err.code = 'DSH_HOST_INCOMPATIBLE'
19
+ return err
20
+ }
@@ -20,6 +20,8 @@ export const Slot = z.object({
20
20
  .description('#67 Optional subscription expiry timestamp (ms). When set and within expiryNotifyDays, the header chip shows the account and expiry date.'),
21
21
  proxyUrl: z.string().default('')
22
22
  .description('#88 Per-account proxy URL (http://, https://, socks5://). All requests for this account route through it. Empty = direct connection.'),
23
+ proxyBypass: z.array(z.string()).default([])
24
+ .description('#803 Hosts/domains that bypass the per-account proxy.'),
23
25
  maxConcurrency: z.number().default(1)
24
26
  .description('#555 Per-slot max concurrent requests before queuing. Defaults to 1 for subscription safety.'),
25
27
  monthlyCost: z.number().default(20)
@@ -155,6 +157,10 @@ export const Config = z.object({
155
157
  .description('#349 Opt-in cross-vendor fallback when all accounts of a provider are exhausted or rate limited.'),
156
158
  cascadingFallbackOrder: z.array(z.string()).volatile().default(PROVIDERS)
157
159
  .description('Configurable global priority sequence for cross-vendor fallback.'),
160
+ modelAccountRules: z.array(z.object({ provider: z.string(), pattern: z.string(), account: z.string() })).volatile().default([])
161
+ .description('#788 Per-provider model glob rules. First match wins; account may be a ref, label or index.'),
162
+ tiers: z.array(z.any()).volatile().default([])
163
+ .description('#817 Named picker tiers: { id, name, members: [accountRef] }. No cross-tier fallback.'),
158
164
  rotationStrategy: z.string().volatile().default('round-robin')
159
165
  .description('Load balancing strategy among available accounts: round-robin (balanced), least-loaded (fair-share quota), quota-aware (urgent quota first), or priority (sequential).'),
160
166
  poolEnabled: z.boolean().volatile().default(true)
@@ -0,0 +1,107 @@
1
+ function hostOf(value) {
2
+ try { return new URL(String(value)).hostname.toLowerCase() } catch { return '' }
3
+ }
4
+
5
+ /** #803: bypass plugin proxy for listed hosts/domains. */
6
+ export function shouldBypassProxy(bypassList, targetUrl) {
7
+ const host = hostOf(targetUrl)
8
+ if (!host) return false
9
+ for (const raw of Array.isArray(bypassList) ? bypassList : []) {
10
+ const entry = String(raw || '').trim().toLowerCase().replace(/^\*\./, '').replace(/^\./, '')
11
+ if (!entry) continue
12
+ if (host === entry || host.endsWith('.' + entry)) return true
13
+ }
14
+ return false
15
+ }
16
+
17
+ /** #803: distinguish proxy transport failure from provider failure. */
18
+ export function classifyConnectivityProbe({ proxyError, providerStatus, latencyMs } = {}) {
19
+ if (proxyError) return { ok: false, kind: 'proxy', error: String(proxyError), latencyMs: null }
20
+ const status = Number(providerStatus) || 0
21
+ return {
22
+ ok: status >= 200 && status < 500,
23
+ kind: status >= 400 ? 'provider' : 'ok',
24
+ status,
25
+ latencyMs: Number.isFinite(Number(latencyMs)) ? Number(latencyMs) : null,
26
+ }
27
+ }
28
+
29
+ /** #817: tiers are independent named pools; no cross-tier fallback. */
30
+ export function validateTiers(tiers) {
31
+ const list = Array.isArray(tiers) ? tiers : []
32
+ const ids = new Set()
33
+ const out = []
34
+ for (const tier of list) {
35
+ if (!tier || typeof tier !== 'object') continue
36
+ const id = String(tier.id || '').trim()
37
+ if (!id || ids.has(id)) continue
38
+ ids.add(id)
39
+ out.push({
40
+ id,
41
+ name: String(tier.name || id),
42
+ members: (Array.isArray(tier.members) ? tier.members : [])
43
+ .map((member) => String(member || '').trim())
44
+ .filter(Boolean),
45
+ })
46
+ }
47
+ return out
48
+ }
49
+
50
+ export function selectTierAccount(tier, accounts, nowMs = Date.now()) {
51
+ const members = new Set((tier && tier.members) || [])
52
+ if (!members.size) return null
53
+ return (Array.isArray(accounts) ? accounts : []).find((account) => account
54
+ && members.has(account.ref)
55
+ && account.hasToken
56
+ && (!account.cooldownUntil || Number(account.cooldownUntil) <= nowMs)
57
+ && (!account.quarantineUntil || Number(account.quarantineUntil) <= nowMs)
58
+ && !(account.usagePercent != null && Number(account.usagePercent) >= 100)) || null
59
+ }
60
+
61
+ /** #832: session policy snapshots are frozen at session creation. */
62
+ export function snapshotToolPolicy(policy, sessionId, at = Date.now()) {
63
+ return { sessionId: String(sessionId || ''), at: Number(at), policy: JSON.parse(JSON.stringify(policy || {})) }
64
+ }
65
+
66
+ export function toolPolicyForSession(snapshots, sessionId) {
67
+ const found = (Array.isArray(snapshots) ? snapshots : []).find((row) => row && row.sessionId === String(sessionId || ''))
68
+ return found ? found.policy : null
69
+ }
70
+
71
+ /** #834: Claude CLI-synced accounts must not fight standalone OAuth over Keychain. */
72
+ export function claudeAccountMode(slot = {}) {
73
+ const raw = String(slot.accountMode || slot.mode || '').toLowerCase()
74
+ if (raw === 'cli-synced' || raw === 'cli') return 'cli-synced'
75
+ return 'standalone'
76
+ }
77
+
78
+ export function canWriteClaudeKeychain(slot, blob, expectedAccountId) {
79
+ if (claudeAccountMode(slot) !== 'cli-synced') return false
80
+ if (!blob) return false
81
+ const owner = String(blob.accountId || blob.email || '').trim()
82
+ const expected = String(expectedAccountId || slot.accountId || slot.email || '').trim()
83
+ return Boolean(owner && expected && owner === expected)
84
+ }
85
+
86
+ /** #815: normalize Codex web-search results and bound retry behavior. */
87
+ export function normalizeWebSearchResult(payload, maxBytes = 64 * 1024) {
88
+ const body = payload && typeof payload === 'object' ? payload : {}
89
+ const text = String(body.output_text || body.answer || body.text || '').slice(0, maxBytes)
90
+ const citations = (Array.isArray(body.citations) ? body.citations : [])
91
+ .map((citation) => {
92
+ if (!citation) return null
93
+ if (typeof citation === 'string') return { url: citation }
94
+ return { url: String(citation.url || citation.link || ''), title: citation.title ? String(citation.title) : undefined }
95
+ })
96
+ .filter((citation) => citation && citation.url)
97
+ .slice(0, 50)
98
+ return { text, citations }
99
+ }
100
+
101
+ export function webSearchRetryDelay(attempt, status) {
102
+ const n = Number(attempt) || 0
103
+ const code = Number(status) || 0
104
+ if (code === 429) return null
105
+ if (code >= 500 && n < 2) return Math.min(2000, 250 * 2 ** n)
106
+ return null
107
+ }
package/lib/rotate.js CHANGED
@@ -2,6 +2,13 @@ import { computePacingRisk, calculateBurnRatePerHour, calculateBurnUrgency } fro
2
2
  import { isQuarantined } from './quarantine.js'
3
3
  import { getPinnedAccountRef, pinSession, touchSessionPin, unpinSession, isCacheAwareProvider } from './session-pin.js'
4
4
  import { accountCatalogHasModel, proactiveQuotaDecision, promptCacheAffinity } from './routing-reliability.js'
5
+ import { matchModelAccountRule } from './routing-rules.js'
6
+
7
+ const roundRobinCursor = new Map()
8
+
9
+ export function resetRoundRobinState() {
10
+ roundRobinCursor.clear()
11
+ }
5
12
 
6
13
  const SWITCH_CODES = new Set([
7
14
  'RATE_LIMIT',
@@ -128,6 +135,7 @@ export function pickAccountWithTrace(accounts, nowMs, opts) {
128
135
  const isVipRequest = Boolean(opts && opts.vip)
129
136
  const targetTag = opts && opts.tag
130
137
  const targetSession = opts && opts.sessionId
138
+ const ruleRef = matchModelAccountRule(opts && opts.modelAccountRules, opts && opts.provider, opts && opts.model, list)
131
139
 
132
140
  const evaluations = []
133
141
 
@@ -156,7 +164,7 @@ export function pickAccountWithTrace(accounts, nowMs, opts) {
156
164
  }
157
165
 
158
166
  // Sticky & Cache-Aware Session Pinning (#170, #517, #520)
159
- if (targetSession) {
167
+ if (targetSession && !ruleRef) {
160
168
  const pinnedRef = getPinnedAccountRef(targetSession, now)
161
169
  if (pinnedRef) {
162
170
  const pinned = list.find((a) => (a.ref || a.id) === pinnedRef)
@@ -388,6 +396,11 @@ export function pickAccountWithTrace(accounts, nowMs, opts) {
388
396
  const aUrgency = calculateBurnUrgency(getAccountRemainingPercent(a), a.quota && a.quota.resetAt, now, floor)
389
397
  const bUrgency = calculateBurnUrgency(getAccountRemainingPercent(b), b.quota && b.quota.resetAt, now, floor)
390
398
  if (Math.abs(aUrgency - bUrgency) > 0.01) return bUrgency - aUrgency
399
+ } else if (strategy === 'lowest-latency') {
400
+ const aLatency = Number(a.lastPingMs ?? a.latencyMs ?? Infinity)
401
+ const bLatency = Number(b.lastPingMs ?? b.latencyMs ?? Infinity)
402
+ if (Number.isFinite(aLatency) && Number.isFinite(bLatency) && Math.abs(aLatency - bLatency) > 1) return aLatency - bLatency
403
+ if (Number.isFinite(aLatency) !== Number.isFinite(bLatency)) return Number.isFinite(aLatency) ? -1 : 1
391
404
  }
392
405
 
393
406
  // #810: keep session traffic on a warm prompt-cache account.
@@ -421,8 +434,23 @@ export function pickAccountWithTrace(accounts, nowMs, opts) {
421
434
  })
422
435
 
423
436
  let chosen = tier[0]
437
+ let decisionReason = tierIdx === 0 ? 'healthy_tier0' : tierIdx === 1 ? 'healthy_tier1' : 'tier2_fallback'
424
438
  const strategy = (opts && opts.rotationStrategy) || 'round-robin'
425
- if (strategy === 'quota-aware' && opts && opts.defaultRef) {
439
+ if (ruleRef) {
440
+ const ruled = tier.find((account) => (account.ref || account.id) === ruleRef)
441
+ if (ruled) {
442
+ chosen = ruled
443
+ decisionReason = 'model_rule'
444
+ }
445
+ } else if (strategy === 'round-robin' && tier.length > 1) {
446
+ const key = tier.map((account) => account.ref || account.id).join('|')
447
+ const last = roundRobinCursor.get(key)
448
+ const index = last ? tier.findIndex((account) => (account.ref || account.id) === last) : -1
449
+ chosen = tier[(index + 1) % tier.length]
450
+ roundRobinCursor.set(key, chosen.ref || chosen.id)
451
+ if (index >= 0) decisionReason = 'round_robin'
452
+ }
453
+ if (!ruleRef && strategy === 'quota-aware' && opts && opts.defaultRef) {
426
454
  const byDefault = tier.find((account) => (account.ref || account.id) === opts.defaultRef)
427
455
  if (byDefault && byDefault !== chosen) {
428
456
  const floor = opts.drainSafetyPercent != null ? Number(opts.drainSafetyPercent) : 5
@@ -443,7 +471,7 @@ export function pickAccountWithTrace(accounts, nowMs, opts) {
443
471
  account: chosen,
444
472
  trace: {
445
473
  chosenRef,
446
- decisionReason: tierIdx === 0 ? 'healthy_tier0' : tierIdx === 1 ? 'healthy_tier1' : 'tier2_fallback',
474
+ decisionReason,
447
475
  tier: tierIdx,
448
476
  evaluations,
449
477
  selectionMetrics: {
@@ -0,0 +1,34 @@
1
+ function escapeRegExp(value) {
2
+ return String(value).replace(/[.+^${}()|[\]\\]/g, '\\$&')
3
+ }
4
+
5
+ export function globToRegExp(pattern) {
6
+ const source = String(pattern || '').trim()
7
+ if (!source) return null
8
+ let out = '^'
9
+ for (const char of source) {
10
+ if (char === '*') out += '.*'
11
+ else if (char === '?') out += '.'
12
+ else out += escapeRegExp(char)
13
+ }
14
+ out += '$'
15
+ try { return new RegExp(out, 'i') } catch { return null }
16
+ }
17
+
18
+ /** #788: first matching provider/model glob rule wins. */
19
+ export function matchModelAccountRule(rules, provider, model, slots) {
20
+ const list = Array.isArray(rules) ? rules : []
21
+ const accounts = Array.isArray(slots) ? slots : []
22
+ for (const rule of list) {
23
+ if (!rule || rule.provider !== provider) continue
24
+ const pattern = globToRegExp(rule.pattern)
25
+ if (!pattern || !pattern.test(String(model || ''))) continue
26
+ const wanted = String(rule.account || '').trim()
27
+ if (!wanted) continue
28
+ const slot = accounts.find((item) => item && item.provider === provider && (
29
+ item.ref === wanted || String(item.label || '') === wanted || String(item.index) === wanted
30
+ ))
31
+ if (slot) return slot.ref
32
+ }
33
+ return null
34
+ }
@@ -54,6 +54,8 @@ export async function* streamWithRotation({
54
54
  model: options && options.model,
55
55
  onlyRef: options && options.onlyRef,
56
56
  defaultRef: options && options.defaultRef,
57
+ provider: options && options.provider,
58
+ modelAccountRules: options && options.modelAccountRules,
57
59
  promptCacheTtlMs: options && options.promptCacheTtlMs,
58
60
  proactiveGuard: options && options.proactiveGuard,
59
61
  proactiveMinRemaining: options && options.proactiveMinRemaining,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.7.38",
3
+ "version": "0.7.40",
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",
@@ -39,7 +39,7 @@
39
39
  "url": "https://github.com/GooDAnDReaDY/dsh-subscriptions/issues"
40
40
  },
41
41
  "scripts": {
42
- "test": "eslint lib/ test/ && npm run typecheck && node --import ./test/_setup.mjs --test test/*.test.mjs && node scripts/check-pack-size.mjs",
42
+ "test": "eslint lib/ test/ && npm run typecheck && node --import ./test/_setup.mjs --test --experimental-test-coverage --test-coverage-lines=85 --test-coverage-branches=70 --test-coverage-functions=75 --test-coverage-include='lib/**/*.js' --test-coverage-exclude='lib/client.js' test/*.test.mjs && node scripts/check-pack-size.mjs",
43
43
  "typecheck": "tsc -p tsconfig.checkjs.json"
44
44
  },
45
45
  "dsh": {