@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.
- package/lib/adapter-manager.js +1 -0
- package/lib/adapter.js +10 -1
- package/lib/client-version.js +35 -0
- package/lib/compat.js +20 -0
- package/lib/config-schema.js +2 -0
- package/lib/reasoning-effort.js +5 -0
- package/lib/rotate.js +31 -3
- package/lib/routing-rules.js +34 -0
- package/lib/stream-rotate.js +2 -0
- package/lib/vendors/grok.js +29 -8
- package/package.json +2 -2
package/lib/adapter-manager.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/lib/config-schema.js
CHANGED
|
@@ -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)
|
package/lib/reasoning-effort.js
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
+
}
|
package/lib/stream-rotate.js
CHANGED
|
@@ -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/lib/vendors/grok.js
CHANGED
|
@@ -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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|