@goodandready/dsh-subscriptions 0.7.38 → 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 +2 -0
- package/lib/client-version.js +35 -0
- package/lib/compat.js +20 -0
- package/lib/config-schema.js +2 -0
- package/lib/rotate.js +31 -3
- package/lib/routing-rules.js +34 -0
- package/lib/stream-rotate.js +2 -0
- 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
|
@@ -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
|
+
}
|
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/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/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": {
|