@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.
- 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 +6 -0
- package/lib/feature-helpers.js +107 -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
|
@@ -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 (
|
|
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.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": {
|