@goodandready/dsh-subscriptions 0.7.29 → 0.7.30
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/accounts-view.js +2 -0
- package/lib/accounts.js +20 -1
- package/lib/adapter-manager.js +3 -1
- package/lib/adapter.js +8 -1
- package/lib/client.js +43 -0
- package/lib/config-schema.js +7 -1
- package/lib/index.js +5 -2
- package/lib/rotate.js +69 -7
- package/lib/stream-rotate.js +9 -3
- package/lib/subscriptions.js +3 -2
- package/package.json +1 -1
package/lib/accounts-view.js
CHANGED
|
@@ -145,6 +145,7 @@ export async function createAccountsView({ live, store, fetch, pmL, pmE, concurr
|
|
|
145
145
|
// Issue 303: expose the cooldown scope and quarantine so the card can
|
|
146
146
|
// explain why only some models are blocked.
|
|
147
147
|
cooldownFamilies: info.cooldownFamilies || null,
|
|
148
|
+
cooldownModels: info.cooldownModels || null,
|
|
148
149
|
quarantineUntil: info.quarantineUntil || 0,
|
|
149
150
|
quarantineReason: info.quarantineReason || null,
|
|
150
151
|
healthScore: info.healthScore,
|
|
@@ -166,6 +167,7 @@ export async function createAccountsView({ live, store, fetch, pmL, pmE, concurr
|
|
|
166
167
|
lastDoctorVerdict: info.lastDoctorVerdict || null,
|
|
167
168
|
availableModels: info.availableModels || null,
|
|
168
169
|
poolEnabled: slot.poolEnabled !== false,
|
|
170
|
+
isDefault: !!slot.isDefault,
|
|
169
171
|
...(Array.isArray(slot.poolModels) ? { poolModels: slot.poolModels } : {}),
|
|
170
172
|
capabilities,
|
|
171
173
|
billing,
|
package/lib/accounts.js
CHANGED
|
@@ -74,6 +74,7 @@ export function normalizeSlots(slots) {
|
|
|
74
74
|
...(slot.expiresAt ? { expiresAt: Number(slot.expiresAt) } : {}),
|
|
75
75
|
...(slot.maxConcurrency !== undefined ? { maxConcurrency: Number(slot.maxConcurrency) } : {}),
|
|
76
76
|
...(slot.poolEnabled === false ? { poolEnabled: false } : {}),
|
|
77
|
+
...(slot.isDefault ? { isDefault: true } : {}),
|
|
77
78
|
...(Array.isArray(slot.poolModels)
|
|
78
79
|
? { poolModels: [...new Set(slot.poolModels.map((id) => String(id).trim()).filter(Boolean))] }
|
|
79
80
|
: {}),
|
|
@@ -82,6 +83,12 @@ export function normalizeSlots(slots) {
|
|
|
82
83
|
return out
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
export function defaultAccountRef(slots, provider) {
|
|
87
|
+
const list = normalizeSlots(slots).filter((slot) => slot.provider === provider)
|
|
88
|
+
const marked = list.find((slot) => slot.isDefault)
|
|
89
|
+
return (marked || list[0] || null)?.ref || null
|
|
90
|
+
}
|
|
91
|
+
|
|
85
92
|
export function vendorConfig(provider, cfg) {
|
|
86
93
|
const d = getVendor(provider).defaults()
|
|
87
94
|
const pick = (suffix, fallback) => {
|
|
@@ -323,6 +330,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
323
330
|
quarantineUntil: quarantines.get(ref) ? quarantines.get(ref).until : 0,
|
|
324
331
|
quarantineReason: quarantines.get(ref) ? quarantines.get(ref).reason : null,
|
|
325
332
|
cooldownFamilies: cooldowns.get(ref) && cooldowns.get(ref).families ? cooldowns.get(ref).families : null,
|
|
333
|
+
cooldownModels: cooldowns.get(ref) && cooldowns.get(ref).models ? cooldowns.get(ref).models : null,
|
|
326
334
|
quota: quotas.get(ref) || null,
|
|
327
335
|
pacePerHour: quotas.get(ref)?.pacePerHour || calculateBurnRatePerHour(quotaSamples.get(ref) || [], Date.now()),
|
|
328
336
|
samples: quotaSamples.get(ref) || [],
|
|
@@ -375,6 +383,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
375
383
|
usagePercent: info.usagePercent,
|
|
376
384
|
cooldownUntil: info.cooldownUntil,
|
|
377
385
|
cooldownFamilies: info.cooldownFamilies || null,
|
|
386
|
+
cooldownModels: info.cooldownModels || null,
|
|
378
387
|
quota: info.quota || null,
|
|
379
388
|
healthScore: computeHealthScore(info.health),
|
|
380
389
|
healthBadge: healthBadge(computeHealthScore(info.health)),
|
|
@@ -389,6 +398,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
389
398
|
weight: Number(slot.weight) || 0,
|
|
390
399
|
isPro: !!slot.isPro,
|
|
391
400
|
poolEnabled: slot.poolEnabled !== false,
|
|
401
|
+
isDefault: !!slot.isDefault,
|
|
392
402
|
...(Array.isArray(slot.poolModels) ? { poolModels: slot.poolModels } : {}),
|
|
393
403
|
})
|
|
394
404
|
}
|
|
@@ -583,7 +593,16 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
583
593
|
recordClockSkew,
|
|
584
594
|
// #87: family-scoped cooldowns. families=null blocks the whole account
|
|
585
595
|
// (legacy behavior); a list blocks only those model families.
|
|
586
|
-
rememberCooldown(ref, until, families) {
|
|
596
|
+
rememberCooldown(ref, until, families, models = null) {
|
|
597
|
+
if (models && typeof models === 'object') {
|
|
598
|
+
const prev = cooldowns.get(ref)
|
|
599
|
+
cooldowns.set(ref, {
|
|
600
|
+
until: prev ? prev.until : 0,
|
|
601
|
+
families: prev ? prev.families : null,
|
|
602
|
+
models: { ...(prev && prev.models ? prev.models : {}), ...models },
|
|
603
|
+
})
|
|
604
|
+
return
|
|
605
|
+
}
|
|
587
606
|
if (!families || !families.length) { cooldowns.set(ref, { until: Number(until) || 0, families: null }); return }
|
|
588
607
|
const prev = cooldowns.get(ref)
|
|
589
608
|
const merged = new Set(families)
|
package/lib/adapter-manager.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { SubscriptionAdapter } from './adapter.js'
|
|
2
2
|
import { resolveFallbackVendor, mapFallbackModel } from './cascade.js'
|
|
3
|
-
import { normalizeSlots, vendorConfig } from './accounts.js'
|
|
3
|
+
import { defaultAccountRef, normalizeSlots, vendorConfig } from './accounts.js'
|
|
4
4
|
import { PROVIDERS, isProvider, oauthRef } from './refs.js'
|
|
5
5
|
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
6
6
|
import { getVendor } from './vendors/index.js'
|
|
@@ -41,6 +41,8 @@ export function createAdapterManager({
|
|
|
41
41
|
listAccounts: (provider) => store.listAccounts(provider),
|
|
42
42
|
getDynamicModels: (ref) => store.getDynamicModels(ref),
|
|
43
43
|
rememberDynamicModels: (ref, models) => store.rememberDynamicModels(ref, models),
|
|
44
|
+
poolEnabled: () => live().poolEnabled !== false,
|
|
45
|
+
defaultAccountRef: (provider) => defaultAccountRef(live().slots, provider),
|
|
44
46
|
loadBlob: (ref) => store.loadBlob(ref),
|
|
45
47
|
ensureFresh: (provider, blob, ref) => store.ensureFresh(provider, blob, ref),
|
|
46
48
|
vendorConfig: (provider) => vendorConfig(provider, live()),
|
package/lib/adapter.js
CHANGED
|
@@ -193,11 +193,18 @@ export class SubscriptionAdapter extends LlmAdapter {
|
|
|
193
193
|
options.signal,
|
|
194
194
|
),
|
|
195
195
|
}
|
|
196
|
+
const poolEnabled = typeof deps.poolEnabled === 'function' ? deps.poolEnabled() !== false : true
|
|
197
|
+
const defaultRef = typeof deps.defaultAccountRef === 'function' ? deps.defaultAccountRef(provider) : null
|
|
196
198
|
const streamOptions = {
|
|
197
199
|
...hydratedOptions,
|
|
198
200
|
...(provider === 'codex' && getSpeedMode(sessionId) === 'fast' ? { service_tier: 'priority' } : {}),
|
|
199
201
|
...(effectiveReasoning.effort ? { reasoningEffort: effectiveReasoning.effort } : {}),
|
|
200
202
|
...(effectiveReasoning.budgetTokens ? { thinkingBudget: effectiveReasoning.budgetTokens } : {}),
|
|
203
|
+
// #830: kill switch. With no default account, use a sentinel that
|
|
204
|
+
// intentionally matches nothing, so the error is explicit and there
|
|
205
|
+
// is no silent fallback to another account.
|
|
206
|
+
...(!poolEnabled ? { onlyRef: defaultRef || '__no_default_account__' } : {}),
|
|
207
|
+
...(defaultRef ? { defaultRef } : {}),
|
|
201
208
|
_decisionTrace: decisionTrace,
|
|
202
209
|
autoPacing: effectiveAutoPacing,
|
|
203
210
|
drainBeforeReset: effectiveDrainBeforeReset,
|
|
@@ -216,7 +223,7 @@ export class SubscriptionAdapter extends LlmAdapter {
|
|
|
216
223
|
webhookUrl: typeof deps.webhookUrl === 'function' ? deps.webhookUrl() : deps.webhookUrl,
|
|
217
224
|
fetchImpl: deps.fetchImpl || fetch,
|
|
218
225
|
onCooldown: (account) => {
|
|
219
|
-
if (typeof deps.rememberCooldown === 'function') deps.rememberCooldown(account.ref, account.cooldownUntil, account.cooldownFamilies || null)
|
|
226
|
+
if (typeof deps.rememberCooldown === 'function') deps.rememberCooldown(account.ref, account.cooldownUntil, account.cooldownFamilies || null, account.cooldownModels || null)
|
|
220
227
|
if (typeof deps.rememberQuarantine === 'function') {
|
|
221
228
|
const currentQ = typeof deps.getQuarantine === 'function' ? deps.getQuarantine(account.ref) : null
|
|
222
229
|
if (currentQ && currentQ.attempts && currentQ.attempts > 1) {
|
package/lib/client.js
CHANGED
|
@@ -158,6 +158,12 @@ window.__ModuleLoader__.load({
|
|
|
158
158
|
"cascadingFallbackOrderHint": "Sequence of alternative providers to try when primary accounts are exhausted.",
|
|
159
159
|
"rotationStrategy": "Account Balancing Strategy",
|
|
160
160
|
"rotationStrategyHint": "Method used to distribute requests among available accounts of a provider.",
|
|
161
|
+
"rotationQuotaAware": "Quota-aware (urgent first)",
|
|
162
|
+
"poolEnabled": "Automatic account pool",
|
|
163
|
+
"poolEnabledHint": "When off, requests use only the provider default account. No silent fallback to other accounts.",
|
|
164
|
+
"mediaPoolEnabled": "Automatic media pool",
|
|
165
|
+
"mediaPoolEnabledHint": "Controls image/video account rotation separately from the text pool.",
|
|
166
|
+
"defaultAccountHint": "Used for direct routing and when the automatic pool is off.",
|
|
161
167
|
"rotationRoundRobin": "Round-Robin (Balanced rotation)",
|
|
162
168
|
"rotationLeastLoaded": "Least-Loaded (Fair-share quota)",
|
|
163
169
|
"rotationPriority": "Priority (Sequential by index/weight)",
|
|
@@ -544,6 +550,12 @@ window.__ModuleLoader__.load({
|
|
|
544
550
|
"cascadingFallbackOrderHint": "首选服务商耗尽时按此顺序依次尝试备选服务商。",
|
|
545
551
|
"rotationStrategy": "账户负载均衡策略",
|
|
546
552
|
"rotationStrategyHint": "在同一服务商的多个可用账户之间分发请求的方式。",
|
|
553
|
+
"rotationQuotaAware": "配额感知(优先紧急)",
|
|
554
|
+
"poolEnabled": "自动账户池",
|
|
555
|
+
"poolEnabledHint": "关闭后请求只使用服务商默认账户,不会静默回退到其他账户。",
|
|
556
|
+
"mediaPoolEnabled": "自动媒体池",
|
|
557
|
+
"mediaPoolEnabledHint": "独立控制图片/视频账户轮询,与文本池分开。",
|
|
558
|
+
"defaultAccountHint": "用于直接路由,以及关闭自动账户池时的请求。",
|
|
547
559
|
"rotationRoundRobin": "轮询均衡(标准交替)",
|
|
548
560
|
"rotationLeastLoaded": "最少负载优先(配额公平共享)",
|
|
549
561
|
"rotationPriority": "优先级顺序(按序号/权重顺序)",
|
|
@@ -2489,10 +2501,33 @@ h('div', { className: 'dsub-row' },
|
|
|
2489
2501
|
},
|
|
2490
2502
|
h('option', { value: 'round-robin' }, t('rotationRoundRobin')),
|
|
2491
2503
|
h('option', { value: 'least-loaded' }, t('rotationLeastLoaded')),
|
|
2504
|
+
h('option', { value: 'quota-aware' }, t('rotationQuotaAware')),
|
|
2492
2505
|
h('option', { value: 'priority' }, t('rotationPriority')),
|
|
2493
2506
|
),
|
|
2494
2507
|
),
|
|
2495
2508
|
),
|
|
2509
|
+
h('div', { className: 'dsub-row', style: { marginTop: 12 } },
|
|
2510
|
+
h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' } },
|
|
2511
|
+
h('input', {
|
|
2512
|
+
type: 'checkbox',
|
|
2513
|
+
checked: draft.poolEnabled !== false,
|
|
2514
|
+
onChange: (e) => setDraft((d) => Object.assign({}, d, { poolEnabled: e.target.checked })),
|
|
2515
|
+
}),
|
|
2516
|
+
h('span', null, t('poolEnabled')),
|
|
2517
|
+
),
|
|
2518
|
+
h('div', { className: 'dsub-sub' }, t('poolEnabledHint')),
|
|
2519
|
+
),
|
|
2520
|
+
h('div', { className: 'dsub-row', style: { marginTop: 8 } },
|
|
2521
|
+
h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' } },
|
|
2522
|
+
h('input', {
|
|
2523
|
+
type: 'checkbox',
|
|
2524
|
+
checked: draft.mediaPoolEnabled !== false,
|
|
2525
|
+
onChange: (e) => setDraft((d) => Object.assign({}, d, { mediaPoolEnabled: e.target.checked })),
|
|
2526
|
+
}),
|
|
2527
|
+
h('span', null, t('mediaPoolEnabled')),
|
|
2528
|
+
),
|
|
2529
|
+
h('div', { className: 'dsub-sub' }, t('mediaPoolEnabledHint')),
|
|
2530
|
+
),
|
|
2496
2531
|
h('div', { className: 'dsub-row', style: { marginTop: 12 } },
|
|
2497
2532
|
h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' } },
|
|
2498
2533
|
h('input', {
|
|
@@ -2781,6 +2816,14 @@ h('div', { className: 'dsub-row' },
|
|
|
2781
2816
|
return h('div', { className: 'dsub-card', key: key },
|
|
2782
2817
|
h('div', { className: 'dsub-row', style: { alignItems: 'center' } },
|
|
2783
2818
|
avatarEl,
|
|
2819
|
+
h('label', { className: 'dsub-ref-tag', title: t('defaultAccountHint') },
|
|
2820
|
+
h('input', {
|
|
2821
|
+
type: 'checkbox',
|
|
2822
|
+
checked: !!row.slot.isDefault,
|
|
2823
|
+
onChange: (e) => updateSlot(row.slot.provider, row.slot.index, { isDefault: e.target.checked }),
|
|
2824
|
+
}),
|
|
2825
|
+
' ★'
|
|
2826
|
+
),
|
|
2784
2827
|
h('input', {
|
|
2785
2828
|
className: 'dsub-grow',
|
|
2786
2829
|
value: row.slot.label || '',
|
package/lib/config-schema.js
CHANGED
|
@@ -36,6 +36,8 @@ export const Slot = z.object({
|
|
|
36
36
|
.description('#171 Treat this account as Pro for weighted rotation.'),
|
|
37
37
|
poolEnabled: z.boolean().default(true)
|
|
38
38
|
.description('#795 Include this account in automatic pool selection. false keeps it available for explicit/manual use only.'),
|
|
39
|
+
isDefault: z.boolean().default(false)
|
|
40
|
+
.description('#829 Mark this account as the provider default for direct/fallback routing.'),
|
|
39
41
|
poolModels: z.array(z.string()).default(undefined)
|
|
40
42
|
.description('#795 Model allowlist for automatic pool selection. Absent = every model, empty = none, explicit list = only those ids.'),
|
|
41
43
|
})
|
|
@@ -154,7 +156,11 @@ export const Config = z.object({
|
|
|
154
156
|
cascadingFallbackOrder: z.array(z.string()).volatile().default(PROVIDERS)
|
|
155
157
|
.description('Configurable global priority sequence for cross-vendor fallback.'),
|
|
156
158
|
rotationStrategy: z.string().volatile().default('round-robin')
|
|
157
|
-
.description('Load balancing strategy among available accounts: round-robin (balanced), least-loaded (fair-share quota), or priority (sequential).'),
|
|
159
|
+
.description('Load balancing strategy among available accounts: round-robin (balanced), least-loaded (fair-share quota), quota-aware (urgent quota first), or priority (sequential).'),
|
|
160
|
+
poolEnabled: z.boolean().volatile().default(true)
|
|
161
|
+
.description('#830 Master switch for automatic account rotation. Off routes only to the provider default account and never silently falls back.'),
|
|
162
|
+
mediaPoolEnabled: z.boolean().volatile().default(true)
|
|
163
|
+
.description('#830 Separate switch for the image/video account pool.'),
|
|
158
164
|
enableSoftFailureSentinel: z.boolean().volatile().default(true)
|
|
159
165
|
.description('Intercept HTTP 200 soft-failure capacity apologies and automatically cascade to next healthy account.'),
|
|
160
166
|
cascadingChain: z.any().volatile().default({})
|
package/lib/index.js
CHANGED
|
@@ -17,7 +17,7 @@ import { registerCustomProviderIds, registerDisplayName } from './refs.js'
|
|
|
17
17
|
import { createSubscriptionsService } from './subscriptions.js'
|
|
18
18
|
import { registerSubscriptionTools } from './native-tools.js'
|
|
19
19
|
import { detectConflicts, readProfilePluginNames, setActiveConflicts } from './coexistence.js'
|
|
20
|
-
import { createAccountStore, normalizeSlots, vendorConfig } from './accounts.js'
|
|
20
|
+
import { createAccountStore, defaultAccountRef, normalizeSlots, vendorConfig } from './accounts.js'
|
|
21
21
|
import { OllamaAdapter, ollamaAlive, ollamaModels, ollamaBase } from './ollama.js'
|
|
22
22
|
import { createResetCreditService } from './reset-credits.js'
|
|
23
23
|
import { maskEmail, maskLabel } from './mask.js'
|
|
@@ -389,12 +389,15 @@ export function apply(ctx, rawConfig) {
|
|
|
389
389
|
throw new Error(`unknown subscription provider: ${provider}`)
|
|
390
390
|
}
|
|
391
391
|
const accounts = await store.listAccounts(provider)
|
|
392
|
+
const mediaPool = live().mediaPoolEnabled !== false
|
|
393
|
+
const onlyRef = mediaPool ? null : defaultAccountRef(live().slots, provider)
|
|
392
394
|
const eligible = pickAccount(accounts || [], Date.now(), {
|
|
393
395
|
family: provider === 'codex' ? 'gpt' : 'grok',
|
|
394
396
|
drainBeforeReset: live().drainBeforeReset,
|
|
395
397
|
drainSafetyPercent: live().drainSafetyPercent,
|
|
398
|
+
...(onlyRef ? { onlyRef } : {}),
|
|
396
399
|
})
|
|
397
|
-
const slot = eligible || (accounts || []).find((row) => row && row.ref && row.hasToken && !poolBlockReason(row, {}))
|
|
400
|
+
const slot = eligible || (accounts || []).find((row) => row && row.ref && row.hasToken && !poolBlockReason(row, {}) && (!onlyRef || row.ref === onlyRef))
|
|
398
401
|
if (!slot) throw new Error(`not logged in to ${provider}: sign in in the Subscriptions section`)
|
|
399
402
|
const raw = await store.resolveRaw(slot.ref)
|
|
400
403
|
if (!raw) throw new Error(`not logged in to ${provider}: sign in in the Subscriptions section`)
|
package/lib/rotate.js
CHANGED
|
@@ -50,7 +50,10 @@ export function modelFamily(provider, model) {
|
|
|
50
50
|
return 'standard'
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
export function cooldownBlocks(acc, family) {
|
|
53
|
+
export function cooldownBlocks(acc, family, model, nowMs = Date.now()) {
|
|
54
|
+
const modelUntil = acc && model && acc.cooldownModels && acc.cooldownModels[model]
|
|
55
|
+
if (modelUntil && Number(modelUntil) > Number(nowMs)) return true
|
|
56
|
+
if (model && acc && acc.cooldownModels && !acc.cooldownUntil) return false
|
|
54
57
|
const fams = acc && acc.cooldownFamilies
|
|
55
58
|
if (!Array.isArray(fams) || !fams.length) return true
|
|
56
59
|
if (!family) return true
|
|
@@ -79,6 +82,26 @@ export function getAccountRemainingPercent(acc) {
|
|
|
79
82
|
return null
|
|
80
83
|
}
|
|
81
84
|
|
|
85
|
+
export function earliestRecovery(accounts, nowMs = Date.now()) {
|
|
86
|
+
const now = Number(nowMs) || 0
|
|
87
|
+
let best = null
|
|
88
|
+
for (const account of Array.isArray(accounts) ? accounts : []) {
|
|
89
|
+
if (!account) continue
|
|
90
|
+
const candidates = []
|
|
91
|
+
if (account.cooldownUntil) candidates.push({ at: Number(account.cooldownUntil), reason: 'cooldown' })
|
|
92
|
+
if (account.quarantineUntil) candidates.push({ at: Number(account.quarantineUntil), reason: account.quarantineReason || 'quarantine' })
|
|
93
|
+
if (account.quota && account.quota.resetAt) candidates.push({ at: Number(account.quota.resetAt), reason: 'quota_reset' })
|
|
94
|
+
for (const [model, until] of Object.entries(account.cooldownModels || {})) {
|
|
95
|
+
if (Number(until) > now) candidates.push({ at: Number(until), reason: 'model_cooldown', model })
|
|
96
|
+
}
|
|
97
|
+
for (const item of candidates) {
|
|
98
|
+
if (!Number.isFinite(item.at) || item.at <= now) continue
|
|
99
|
+
if (!best || item.at < best.at) best = { ref: account.ref || account.id || 'unknown', ...item }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return best
|
|
103
|
+
}
|
|
104
|
+
|
|
82
105
|
export function poolBlockReason(account, opts) {
|
|
83
106
|
if (!account) return null
|
|
84
107
|
if (account.poolEnabled === false) return 'pool_disabled'
|
|
@@ -93,7 +116,11 @@ export function poolBlockReason(account, opts) {
|
|
|
93
116
|
}
|
|
94
117
|
|
|
95
118
|
export function pickAccountWithTrace(accounts, nowMs, opts) {
|
|
96
|
-
const
|
|
119
|
+
const allAccounts = Array.isArray(accounts) ? accounts : []
|
|
120
|
+
const onlyRef = opts && opts.onlyRef
|
|
121
|
+
const list = onlyRef
|
|
122
|
+
? allAccounts.filter((account) => account && (account.ref || account.id) === onlyRef)
|
|
123
|
+
: allAccounts
|
|
97
124
|
const now = Number(nowMs) || 0
|
|
98
125
|
const thrRaw = opts && opts.switchAtRemaining != null ? Number(opts.switchAtRemaining) : 0
|
|
99
126
|
const thr = Number.isFinite(thrRaw) ? thrRaw : 0
|
|
@@ -142,7 +169,8 @@ export function pickAccountWithTrace(accounts, nowMs, opts) {
|
|
|
142
169
|
pinned: true,
|
|
143
170
|
})
|
|
144
171
|
} else if (pinned && pinned.hasToken && !isQuarantined(pinned, now)) {
|
|
145
|
-
const
|
|
172
|
+
const pinnedModelCooling = opts && opts.model && pinned.cooldownModels && Number(pinned.cooldownModels[opts.model] || 0) > now
|
|
173
|
+
const isCooldown = pinnedModelCooling || (pinned.cooldownUntil && Number(pinned.cooldownUntil) > now && cooldownBlocks(pinned, opts && opts.family, opts && opts.model, now))
|
|
146
174
|
const isPinnedZero = Boolean(pinned.quota && (
|
|
147
175
|
(pinned.quota.remaining != null && Number(pinned.quota.remaining) <= 0) ||
|
|
148
176
|
(pinned.quota.remainingPercent != null && Number(pinned.quota.remainingPercent) <= 0)
|
|
@@ -252,9 +280,17 @@ export function pickAccountWithTrace(accounts, nowMs, opts) {
|
|
|
252
280
|
continue
|
|
253
281
|
}
|
|
254
282
|
|
|
255
|
-
const
|
|
283
|
+
const modelCooling = opts && opts.model && acc.cooldownModels && Number(acc.cooldownModels[opts.model] || 0) > now
|
|
284
|
+
const isCooldown = modelCooling || (acc.cooldownUntil && Number(acc.cooldownUntil) > now && cooldownBlocks(acc, opts && opts.family, opts && opts.model, now))
|
|
256
285
|
if (isCooldown) {
|
|
257
|
-
evaluations.push({
|
|
286
|
+
evaluations.push({
|
|
287
|
+
ref,
|
|
288
|
+
eligible: false,
|
|
289
|
+
tier: null,
|
|
290
|
+
reason: modelCooling ? 'model_cooldown' : 'cooldown',
|
|
291
|
+
until: modelCooling ? acc.cooldownModels[opts.model] : acc.cooldownUntil,
|
|
292
|
+
...(modelCooling ? { model: opts.model } : {}),
|
|
293
|
+
})
|
|
258
294
|
continue
|
|
259
295
|
}
|
|
260
296
|
|
|
@@ -333,6 +369,11 @@ export function pickAccountWithTrace(accounts, nowMs, opts) {
|
|
|
333
369
|
const aIdx = list.indexOf(a)
|
|
334
370
|
const bIdx = list.indexOf(b)
|
|
335
371
|
if (aIdx !== -1 && bIdx !== -1 && aIdx !== bIdx) return aIdx - bIdx
|
|
372
|
+
} else if (strategy === 'quota-aware') {
|
|
373
|
+
const floor = opts && opts.drainSafetyPercent != null ? Number(opts.drainSafetyPercent) : 5
|
|
374
|
+
const aUrgency = calculateBurnUrgency(getAccountRemainingPercent(a), a.quota && a.quota.resetAt, now, floor)
|
|
375
|
+
const bUrgency = calculateBurnUrgency(getAccountRemainingPercent(b), b.quota && b.quota.resetAt, now, floor)
|
|
376
|
+
if (Math.abs(aUrgency - bUrgency) > 0.01) return bUrgency - aUrgency
|
|
336
377
|
}
|
|
337
378
|
|
|
338
379
|
// 0. Auto-Pacing: lower pacing risk preferred (#352)
|
|
@@ -358,7 +399,17 @@ export function pickAccountWithTrace(accounts, nowMs, opts) {
|
|
|
358
399
|
return (b.healthScore ?? 100) - (a.healthScore ?? 100)
|
|
359
400
|
})
|
|
360
401
|
|
|
361
|
-
|
|
402
|
+
let chosen = tier[0]
|
|
403
|
+
const strategy = (opts && opts.rotationStrategy) || 'round-robin'
|
|
404
|
+
if (strategy === 'quota-aware' && opts && opts.defaultRef) {
|
|
405
|
+
const byDefault = tier.find((account) => (account.ref || account.id) === opts.defaultRef)
|
|
406
|
+
if (byDefault && byDefault !== chosen) {
|
|
407
|
+
const floor = opts.drainSafetyPercent != null ? Number(opts.drainSafetyPercent) : 5
|
|
408
|
+
const chosenUrgency = calculateBurnUrgency(getAccountRemainingPercent(chosen), chosen.quota && chosen.quota.resetAt, now, floor)
|
|
409
|
+
const defaultUrgency = calculateBurnUrgency(getAccountRemainingPercent(byDefault), byDefault.quota && byDefault.quota.resetAt, now, floor)
|
|
410
|
+
if (defaultUrgency >= chosenUrgency - 5) chosen = byDefault
|
|
411
|
+
}
|
|
412
|
+
}
|
|
362
413
|
const chosenRef = chosen.ref || chosen.id
|
|
363
414
|
if (targetSession && chosen) {
|
|
364
415
|
pinSession(targetSession, chosenRef, {
|
|
@@ -382,6 +433,8 @@ export function pickAccountWithTrace(accounts, nowMs, opts) {
|
|
|
382
433
|
resetAt: chosen.quota?.resetAt || null,
|
|
383
434
|
weight: chosen.weight || (chosen.isPro ? 5 : 1),
|
|
384
435
|
healthScore: chosen.healthScore ?? 100,
|
|
436
|
+
urgency: calculateBurnUrgency(getAccountRemainingPercent(chosen), chosen.quota && chosen.quota.resetAt, now, opts && opts.drainSafetyPercent != null ? Number(opts.drainSafetyPercent) : 5),
|
|
437
|
+
defaultAccount: Boolean(opts && opts.defaultRef && chosenRef === opts.defaultRef),
|
|
385
438
|
},
|
|
386
439
|
},
|
|
387
440
|
}
|
|
@@ -405,12 +458,21 @@ export function pickAccount(accounts, nowMs, opts) {
|
|
|
405
458
|
return res ? res.account : null
|
|
406
459
|
}
|
|
407
460
|
|
|
408
|
-
export function markCooldown(account, nowMs, cooldownMs, family, customUntil = null) {
|
|
461
|
+
export function markCooldown(account, nowMs, cooldownMs, family, customUntil = null, model = null) {
|
|
409
462
|
const wait = Number(cooldownMs)
|
|
410
463
|
const ms = Number.isFinite(wait) && wait > 0 ? wait : 30 * 60 * 1000
|
|
411
464
|
const until = (customUntil && Number(customUntil) > (Number(nowMs) || 0))
|
|
412
465
|
? Number(customUntil)
|
|
413
466
|
: ((Number(nowMs) || 0) + ms)
|
|
467
|
+
// #814: Claude/Antigravity quotas are model-specific. Auth/session errors
|
|
468
|
+
// are handled elsewhere as account-wide; ordinary model quota failures stay
|
|
469
|
+
// scoped to the failing model.
|
|
470
|
+
if (model && (account.provider === 'claude' || account.provider === 'antigravity')) {
|
|
471
|
+
return {
|
|
472
|
+
...account,
|
|
473
|
+
cooldownModels: { ...(account.cooldownModels || {}), [model]: until },
|
|
474
|
+
}
|
|
475
|
+
}
|
|
414
476
|
const prev = Array.isArray(account.cooldownFamilies) ? account.cooldownFamilies.slice() : []
|
|
415
477
|
const fams = family ? (prev.includes(family) ? prev : prev.concat(family)) : prev
|
|
416
478
|
return {
|
package/lib/stream-rotate.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { inspectAccountCapabilities, normalizeCapabilitiesRequest } from './capabilities.js'
|
|
2
2
|
import { defaultConcurrencyQueue } from './concurrency-queue.js'
|
|
3
3
|
import { recordSessionCacheUsage } from './session-pin.js'
|
|
4
|
-
import { pickAccountWithTrace, markCooldown, isSwitchableError, isRegionError, isAuthError, modelFamily } from './rotate.js'
|
|
4
|
+
import { pickAccountWithTrace, markCooldown, isSwitchableError, isRegionError, isAuthError, modelFamily, earliestRecovery } from './rotate.js'
|
|
5
5
|
import { putInQuarantine, REASON_RATE_LIMIT, REASON_HARD_LIMIT, REASON_REVOKED, REASON_DEGRADED } from './quarantine.js'
|
|
6
6
|
import { notifySessionExpired, notifyQuarantineEntered } from './alerts.js'
|
|
7
7
|
import { withRunawayGuard } from './runaway-guard.js'
|
|
@@ -52,6 +52,8 @@ export async function* streamWithRotation({
|
|
|
52
52
|
const pickRes = pickAccountWithTrace(pool, nowMs(), {
|
|
53
53
|
switchAtRemaining,
|
|
54
54
|
model: options && options.model,
|
|
55
|
+
onlyRef: options && options.onlyRef,
|
|
56
|
+
defaultRef: options && options.defaultRef,
|
|
55
57
|
family: modelFamily(options && options.provider, options && options.model),
|
|
56
58
|
sessionId: options && options.sessionId,
|
|
57
59
|
tag: options && options.tag,
|
|
@@ -96,10 +98,13 @@ export async function* streamWithRotation({
|
|
|
96
98
|
lastError.retryAfterMs = Math.min(wait, maxWait)
|
|
97
99
|
lastError.resetAt = nowMs() + lastError.retryAfterMs
|
|
98
100
|
}
|
|
101
|
+
lastError.recovery = lastError.recovery || earliestRecovery(pool, nowMs())
|
|
99
102
|
throw lastError
|
|
100
103
|
}
|
|
101
|
-
const err = /** @type {Error & { code?: string }} */ (new Error('no usable subscription account for this provider'))
|
|
104
|
+
const err = /** @type {Error & { code?: string, recovery?: any, onlyRef?: string }} */ (new Error('no usable subscription account for this provider'))
|
|
102
105
|
err.code = 'RATE_LIMIT'
|
|
106
|
+
err.recovery = earliestRecovery(pool, nowMs())
|
|
107
|
+
if (options && options.onlyRef) err.onlyRef = options.onlyRef
|
|
103
108
|
throw err
|
|
104
109
|
}
|
|
105
110
|
|
|
@@ -233,9 +238,10 @@ export async function* streamWithRotation({
|
|
|
233
238
|
|
|
234
239
|
// Move slot to cooldown and quarantine (#172, #538)
|
|
235
240
|
const effectiveCooldownMs = isSoft ? Math.min(cooldownMs, 5 * 60 * 1000) : (isAuth ? 86400000 : cooldownMs)
|
|
236
|
-
const cooled = markCooldown(account, nowMs(), effectiveCooldownMs, modelFamily(options && options.provider, options && options.model), accurateUntil)
|
|
241
|
+
const cooled = markCooldown(account, nowMs(), effectiveCooldownMs, modelFamily(options && options.provider, options && options.model), accurateUntil, options && options.model)
|
|
237
242
|
account.cooldownUntil = cooled.cooldownUntil
|
|
238
243
|
if (cooled.cooldownFamilies) account.cooldownFamilies = cooled.cooldownFamilies
|
|
244
|
+
if (cooled.cooldownModels) account.cooldownModels = cooled.cooldownModels
|
|
239
245
|
|
|
240
246
|
const status = Number(err && (err.status || err.statusCode) || 0)
|
|
241
247
|
const code = String(err && err.code || '')
|
package/lib/subscriptions.js
CHANGED
|
@@ -299,9 +299,10 @@ export function createSubscriptionsService(deps) {
|
|
|
299
299
|
currentAttempt.switchError = err ? (err.code || err.status || err.message) : 'switchable'
|
|
300
300
|
lastError = err
|
|
301
301
|
if (!isSwitchableError(err)) throw err
|
|
302
|
-
const cooled = markCooldown(account, Date.now(), cooldownMs, modelFamily(provider, body && body.model))
|
|
302
|
+
const cooled = markCooldown(account, Date.now(), cooldownMs, modelFamily(provider, body && body.model), null, body && body.model)
|
|
303
303
|
account.cooldownUntil = cooled.cooldownUntil
|
|
304
|
-
if (
|
|
304
|
+
if (cooled.cooldownModels) account.cooldownModels = cooled.cooldownModels
|
|
305
|
+
if (typeof deps.rememberCooldown === "function") deps.rememberCooldown(account.ref, account.cooldownUntil, cooled.cooldownFamilies || null, cooled.cooldownModels || null)
|
|
305
306
|
// try next account
|
|
306
307
|
}
|
|
307
308
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-subscriptions",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.30",
|
|
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",
|