@goodandready/dsh-subscriptions 0.7.37 → 0.7.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.
@@ -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
@@ -59,8 +59,15 @@ export class SubscriptionAdapter extends LlmAdapter {
59
59
  const builtin = modelCatalog(provider, cfg ? cfg.models : undefined)
60
60
  const targetAccount = accounts.find((a) => a.hasToken)
61
61
  let targetLive = null
62
+ const targetCached = targetAccount && typeof this.deps.getDynamicModels === 'function'
63
+ ? this.deps.getDynamicModels(targetAccount.ref)
64
+ : null
62
65
 
63
- if (targetAccount) {
66
+ // #27: resolution runs on every request. Do not hit live metadata when a
67
+ // last-known-good per-account snapshot already exists; background refresh
68
+ // updates it without turning a temporary metadata outage into a capability
69
+ // verdict.
70
+ if (targetAccount && !(Array.isArray(targetCached) && targetCached.length)) {
64
71
  const baseFetch = pickFetch(this.deps, targetAccount.ref)
65
72
  const listModelsFetch = (url, init, opts) =>
66
73
  fetchWithTimeout(baseFetch, url, init, { timeoutMs: 15000, ...(opts || {}) })
@@ -214,6 +221,8 @@ export class SubscriptionAdapter extends LlmAdapter {
214
221
  drainBeforeReset: effectiveDrainBeforeReset,
215
222
  drainSafetyPercent: effectiveDrainSafetyPercent,
216
223
  rotationStrategy: effectiveRotationStrategy,
224
+ modelAccountRules: typeof deps.modelAccountRules === 'function' ? deps.modelAccountRules() : [],
225
+ provider,
217
226
  enableSoftFailureSentinel: effectiveSoftFailureSentinel,
218
227
  rateLimitWait: typeof deps.rateLimitWait === 'function' ? deps.rateLimitWait() : deps.rateLimitWait,
219
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
+ }
@@ -155,6 +155,8 @@ export const Config = z.object({
155
155
  .description('#349 Opt-in cross-vendor fallback when all accounts of a provider are exhausted or rate limited.'),
156
156
  cascadingFallbackOrder: z.array(z.string()).volatile().default(PROVIDERS)
157
157
  .description('Configurable global priority sequence for cross-vendor fallback.'),
158
+ modelAccountRules: z.array(z.object({ provider: z.string(), pattern: z.string(), account: z.string() })).volatile().default([])
159
+ .description('#788 Per-provider model glob rules. First match wins; account may be a ref, label or index.'),
158
160
  rotationStrategy: z.string().volatile().default('round-robin')
159
161
  .description('Load balancing strategy among available accounts: round-robin (balanced), least-loaded (fair-share quota), quota-aware (urgent quota first), or priority (sequential).'),
160
162
  poolEnabled: z.boolean().volatile().default(true)
@@ -97,6 +97,11 @@ export function getModelReasoningCapabilities(provider, modelId, catalog = []) {
97
97
  if (id.includes('reasoning') || id === 'grok-3-mini') {
98
98
  return { supported: true, levels: ['low', 'medium', 'high'], defaultEffort: 'medium' }
99
99
  }
100
+ // #27: grok-4.x supports reasoning effort even when the best-effort CLI
101
+ // metadata endpoint is temporarily unreachable.
102
+ if (/^grok-4(?:[.-]|$)/i.test(id)) {
103
+ return { supported: true, levels: ['low', 'medium', 'high'], defaultEffort: 'high' }
104
+ }
100
105
  return { supported: false, levels: [] }
101
106
  }
102
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,
@@ -43,18 +43,37 @@ async function cliCatalogCached(blob, cfg, fetchImpl) {
43
43
  const now = Date.now()
44
44
  const hit = catalogCache.get(key)
45
45
  if (hit && now - hit.at < CATALOG_TTL_MS) return hit.map
46
- if (hit) catalogCache.delete(key)
47
- const map = await cliCatalog(blob, cfg, fetchImpl)
48
- if (map.size > 0) {
49
- catalogCache.set(key, { at: now, map })
50
- pruneCatalogCache(now)
46
+ try {
47
+ const map = await cliCatalog(blob, cfg, fetchImpl)
48
+ if (map.size > 0) {
49
+ catalogCache.set(key, { at: now, map })
50
+ pruneCatalogCache(now)
51
+ return map
52
+ }
53
+ // An empty response must not erase the last-known-good metadata.
54
+ if (hit) return hit.map
55
+ return map
56
+ } catch (err) {
57
+ if (hit) return hit.map
58
+ throw err
59
+ }
60
+ }
61
+
62
+ function grokFallbackReasoning(modelId) {
63
+ if (!/^grok-4(?:[.-]|$)/i.test(String(modelId || ''))) return undefined
64
+ return {
65
+ efforts: [
66
+ { id: 'high', name: 'High' },
67
+ { id: 'medium', name: 'Medium' },
68
+ { id: 'low', name: 'Low' },
69
+ ],
70
+ defaultEffort: 'high',
51
71
  }
52
- return map
53
72
  }
54
73
 
55
74
  function grokReasoningBody(modelId, effort, catalogMap) {
56
75
  const entry = catalogMap && catalogMap.get(modelId)
57
- const reasoning = entry && entry.reasoning
76
+ const reasoning = (entry && entry.reasoning) || grokFallbackReasoning(modelId)
58
77
  if (!reasoning || !reasoning.efforts || !reasoning.efforts.length) return undefined
59
78
  const allowed = new Set(reasoning.efforts.map((row) => row.id))
60
79
  const pick = effort != null && effort !== '' ? String(effort) : ''
@@ -221,7 +240,7 @@ export async function listModels(blob, cfg, fetchImpl) {
221
240
  try {
222
241
  const [res, extra] = await Promise.all([
223
242
  impl(MODELS, { headers: { Authorization: `Bearer ${blob.accessToken}`, Accept: 'application/json' } }),
224
- cliCatalog(blob, cfg, impl).catch(() => new Map()),
243
+ cliCatalogCached(blob, cfg, impl).catch(() => new Map()),
225
244
  ])
226
245
  const json = await readJson(res)
227
246
  const rows = []
@@ -229,6 +248,7 @@ export async function listModels(blob, cfg, fetchImpl) {
229
248
  const modelId = entry && entry.id
230
249
  if (!modelId || !isChatModel(modelId)) continue
231
250
  const enrich = extra.get(modelId) || {}
251
+ const reasoning = enrich.reasoning || grokFallbackReasoning(modelId)
232
252
  const note = /multi-agent/i.test(modelId)
233
253
  ? 'Chat only in Harness until xAI grants multi-agent tool beta.'
234
254
  : ''
@@ -237,6 +257,7 @@ export async function listModels(blob, cfg, fetchImpl) {
237
257
  name: enrich.name || modelId,
238
258
  inputModalities: grokModalities(modelId),
239
259
  ...enrich,
260
+ ...(reasoning ? { reasoning } : {}),
240
261
  ...(note ? { description: enrich.description ? `${enrich.description} ${note}` : note } : {}),
241
262
  })
242
263
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.7.37",
3
+ "version": "0.7.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",
@@ -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": {