@goodandready/dsh-subscriptions 0.7.29 → 0.7.31

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.
@@ -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)
@@ -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)",
@@ -196,6 +202,10 @@ window.__ModuleLoader__.load({
196
202
  "modelShowMore": "Show more",
197
203
  "modelShowAll": "Show all",
198
204
  "modelNoMatches": "No models match this search.",
205
+ "modelCatalogSourceHint": "Catalog source: live, cached or builtin",
206
+ "modelNamesTitle": "Model display names",
207
+ "modelNamesHint": "Presentation only: routing, reasoning and model ids stay unchanged. Reset restores the catalog name.",
208
+ "modelNameReset": "Reset",
199
209
  "rotationDecisionsTitle": "Rotation & Routing Decisions",
200
210
  "rotationDecisionsDesc": "Real-time decision trace explaining account selection, skips, and fallback reasons.",
201
211
  "rotationAttempt": "attempt",
@@ -302,7 +312,10 @@ window.__ModuleLoader__.load({
302
312
  "maxQueueSizeSlot": "Queue",
303
313
  "save": "Save",
304
314
  "saved": "Saved",
315
+ "cancel": "Cancel",
316
+ "unsavedChanges": "Unsaved changes",
305
317
  "accountLabel": "Account",
318
+ "usageRemaining": "Remaining quota",
306
319
  "plan": "Plan",
307
320
  "storedAs": "Stored as",
308
321
  "slashFast": "Toggle Codex Speed (fast/standard)",
@@ -544,6 +557,12 @@ window.__ModuleLoader__.load({
544
557
  "cascadingFallbackOrderHint": "首选服务商耗尽时按此顺序依次尝试备选服务商。",
545
558
  "rotationStrategy": "账户负载均衡策略",
546
559
  "rotationStrategyHint": "在同一服务商的多个可用账户之间分发请求的方式。",
560
+ "rotationQuotaAware": "配额感知(优先紧急)",
561
+ "poolEnabled": "自动账户池",
562
+ "poolEnabledHint": "关闭后请求只使用服务商默认账户,不会静默回退到其他账户。",
563
+ "mediaPoolEnabled": "自动媒体池",
564
+ "mediaPoolEnabledHint": "独立控制图片/视频账户轮询,与文本池分开。",
565
+ "defaultAccountHint": "用于直接路由,以及关闭自动账户池时的请求。",
547
566
  "rotationRoundRobin": "轮询均衡(标准交替)",
548
567
  "rotationLeastLoaded": "最少负载优先(配额公平共享)",
549
568
  "rotationPriority": "优先级顺序(按序号/权重顺序)",
@@ -582,6 +601,10 @@ window.__ModuleLoader__.load({
582
601
  "modelShowMore": "显示更多",
583
602
  "modelShowAll": "显示全部",
584
603
  "modelNoMatches": "没有匹配的模型。",
604
+ "modelCatalogSourceHint": "目录来源:实时、缓存或内置",
605
+ "modelNamesTitle": "模型显示名称",
606
+ "modelNamesHint": "仅影响显示:路由、推理和模型 ID 不变。重置恢复目录名称。",
607
+ "modelNameReset": "重置",
585
608
  "rotationDecisionsTitle": "轮询调度与路由决策",
586
609
  "rotationDecisionsDesc": "实时决策追踪,透明展示每次请求的账号选择、跳过与回退原因。",
587
610
  "rotationAttempt": "次尝试",
@@ -688,7 +711,10 @@ window.__ModuleLoader__.load({
688
711
  "maxQueueSizeSlot": "队列",
689
712
  "save": "保存",
690
713
  "saved": "已保存",
714
+ "cancel": "取消",
715
+ "unsavedChanges": "有未保存的更改",
691
716
  "accountLabel": "账户",
717
+ "usageRemaining": "剩余配额",
692
718
  "plan": "套餐方案",
693
719
  "storedAs": "存储标识",
694
720
  "slashFast": "切换 Codex 加速模式(快速/标准)",
@@ -1407,6 +1433,7 @@ window.__ModuleLoader__.load({
1407
1433
  }
1408
1434
  const [checking, setChecking] = React.useState({})
1409
1435
  const [saved, setSaved] = React.useState(false)
1436
+ const [savedSnapshot, setSavedSnapshot] = React.useState(null)
1410
1437
  const [tokenDraft, setTokenDraft] = React.useState({})
1411
1438
  const [openHelp, setOpenHelp] = React.useState({})
1412
1439
  const [err, setErr] = React.useState('')
@@ -1428,7 +1455,9 @@ window.__ModuleLoader__.load({
1428
1455
 
1429
1456
  const applyPayload = (data) => {
1430
1457
  if (data && data.revision != null) setRevision(data.revision)
1431
- setDraft(JSON.parse(JSON.stringify((data && data.config) || {})))
1458
+ const next = JSON.parse(JSON.stringify((data && data.config) || {}))
1459
+ setDraft(next)
1460
+ setSavedSnapshot(JSON.parse(JSON.stringify(next)))
1432
1461
  setAccounts((data && data.accounts) || [])
1433
1462
  setProviders((data && data.providers) || [])
1434
1463
  setModelsCatalog((data && data.modelsCatalog) || [])
@@ -1480,6 +1509,18 @@ window.__ModuleLoader__.load({
1480
1509
  return () => timers.forEach(clearInterval)
1481
1510
  }, [device])
1482
1511
 
1512
+ const dirty = Boolean(draft && savedSnapshot && JSON.stringify(draft) !== JSON.stringify(savedSnapshot))
1513
+ React.useEffect(() => {
1514
+ if (!dirty) return undefined
1515
+ const warn = (event) => {
1516
+ event.preventDefault()
1517
+ event.returnValue = ''
1518
+ return ''
1519
+ }
1520
+ window.addEventListener('beforeunload', warn)
1521
+ return () => window.removeEventListener('beforeunload', warn)
1522
+ }, [dirty])
1523
+
1483
1524
  // #81: settings snapshot status - never render phantom inputs before the
1484
1525
  // config snapshot arrives, and offer a retry when the store is unavailable.
1485
1526
  if (!draft) {
@@ -2366,6 +2407,7 @@ h('div', { className: 'dsub-row' },
2366
2407
  m.provider ? h('span', { className: 'dsub-brand dsub-brand-' + m.provider, style: { fontSize: '9px', padding: '1px 4px' } }, m.provider.toUpperCase()) : null,
2367
2408
  h('code', null, m.name || m.id),
2368
2409
  tags.length ? h('span', { className: 'dsub-model-tag' }, tags.join(' · ')) : null,
2410
+ Array.isArray(m.catalogSources) && m.catalogSources.length ? h('span', { className: 'dsub-model-tag', title: t('modelCatalogSourceHint') }, '📚 ' + m.catalogSources.join('/')) : null,
2369
2411
  h('span', { className: 'dsub-model-tag ' + statusClass }, poolLabel),
2370
2412
  )
2371
2413
  })
@@ -2382,6 +2424,46 @@ h('div', { className: 'dsub-row' },
2382
2424
  }, t('modelShowAll')),
2383
2425
  ) : null,
2384
2426
  ),
2427
+ h('details', { className: 'dsub-group', 'data-pane': 'models' },
2428
+ h('summary', { className: 'dsub-groupHead' }, '🏷 ' + t('modelNamesTitle')),
2429
+ h('div', { className: 'dsub-sub', style: { marginTop: 4 } }, t('modelNamesHint')),
2430
+ h('div', { style: { maxHeight: 260, overflow: 'auto', marginTop: 6 } },
2431
+ catalogShown.slice(0, 50).map((model) => {
2432
+ const current = (Array.isArray(draft.modelDisplayNames) ? draft.modelDisplayNames : [])
2433
+ .find((row) => row && row.provider === model.provider && String(row.id) === String(model.id))
2434
+ return h('div', { key: 'name:' + model.provider + ':' + model.id, className: 'dsub-row', style: { gap: 6 } },
2435
+ h('code', { style: { minWidth: 120, fontSize: 11 } }, model.id),
2436
+ h('input', {
2437
+ className: 'dsub-inp dsub-grow',
2438
+ value: current ? current.name : '',
2439
+ placeholder: model.name || model.id,
2440
+ onChange: (e) => {
2441
+ const value = e.target.value
2442
+ setDraft((d) => {
2443
+ const list = Array.isArray(d.modelDisplayNames) ? d.modelDisplayNames.slice() : []
2444
+ const idx = list.findIndex((row) => row && row.provider === model.provider && String(row.id) === String(model.id))
2445
+ if (value.trim()) {
2446
+ const next = { provider: model.provider, id: String(model.id), name: value.slice(0, 80) }
2447
+ if (idx >= 0) list[idx] = next
2448
+ else list.push(next)
2449
+ } else if (idx >= 0) {
2450
+ list.splice(idx, 1)
2451
+ }
2452
+ return Object.assign({}, d, { modelDisplayNames: list })
2453
+ })
2454
+ },
2455
+ }),
2456
+ h('button', {
2457
+ type: 'button', className: 'dsub-mini',
2458
+ onClick: () => setDraft((d) => Object.assign({}, d, {
2459
+ modelDisplayNames: (Array.isArray(d.modelDisplayNames) ? d.modelDisplayNames : [])
2460
+ .filter((row) => !(row && row.provider === model.provider && String(row.id) === String(model.id))),
2461
+ })),
2462
+ }, t('modelNameReset')),
2463
+ )
2464
+ })
2465
+ ),
2466
+ ),
2385
2467
  h('details', { className: 'dsub-group', 'data-pane': 'models' },
2386
2468
  h('summary', { className: 'dsub-groupHead' }, '🧠 ' + t('reasoningEffortTitle')),
2387
2469
  h('div', { className: 'dsub-sub', style: { marginTop: '4px' } }, t('reasoningEffortDesc')),
@@ -2489,10 +2571,33 @@ h('div', { className: 'dsub-row' },
2489
2571
  },
2490
2572
  h('option', { value: 'round-robin' }, t('rotationRoundRobin')),
2491
2573
  h('option', { value: 'least-loaded' }, t('rotationLeastLoaded')),
2574
+ h('option', { value: 'quota-aware' }, t('rotationQuotaAware')),
2492
2575
  h('option', { value: 'priority' }, t('rotationPriority')),
2493
2576
  ),
2494
2577
  ),
2495
2578
  ),
2579
+ h('div', { className: 'dsub-row', style: { marginTop: 12 } },
2580
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' } },
2581
+ h('input', {
2582
+ type: 'checkbox',
2583
+ checked: draft.poolEnabled !== false,
2584
+ onChange: (e) => setDraft((d) => Object.assign({}, d, { poolEnabled: e.target.checked })),
2585
+ }),
2586
+ h('span', null, t('poolEnabled')),
2587
+ ),
2588
+ h('div', { className: 'dsub-sub' }, t('poolEnabledHint')),
2589
+ ),
2590
+ h('div', { className: 'dsub-row', style: { marginTop: 8 } },
2591
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' } },
2592
+ h('input', {
2593
+ type: 'checkbox',
2594
+ checked: draft.mediaPoolEnabled !== false,
2595
+ onChange: (e) => setDraft((d) => Object.assign({}, d, { mediaPoolEnabled: e.target.checked })),
2596
+ }),
2597
+ h('span', null, t('mediaPoolEnabled')),
2598
+ ),
2599
+ h('div', { className: 'dsub-sub' }, t('mediaPoolEnabledHint')),
2600
+ ),
2496
2601
  h('div', { className: 'dsub-row', style: { marginTop: 12 } },
2497
2602
  h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' } },
2498
2603
  h('input', {
@@ -2781,6 +2886,14 @@ h('div', { className: 'dsub-row' },
2781
2886
  return h('div', { className: 'dsub-card', key: key },
2782
2887
  h('div', { className: 'dsub-row', style: { alignItems: 'center' } },
2783
2888
  avatarEl,
2889
+ h('label', { className: 'dsub-ref-tag', title: t('defaultAccountHint') },
2890
+ h('input', {
2891
+ type: 'checkbox',
2892
+ checked: !!row.slot.isDefault,
2893
+ onChange: (e) => updateSlot(row.slot.provider, row.slot.index, { isDefault: e.target.checked }),
2894
+ }),
2895
+ ' ★'
2896
+ ),
2784
2897
  h('input', {
2785
2898
  className: 'dsub-grow',
2786
2899
  value: row.slot.label || '',
@@ -2865,6 +2978,9 @@ h('div', { className: 'dsub-row' },
2865
2978
  },
2866
2979
  }, '\u00d7'),
2867
2980
  ),
2981
+ (account && (account.quota?.remainingPercent != null || account.usagePercent != null)) ? h('div', { className: 'dsub-sub', style: { marginTop: 4 } },
2982
+ '📊 ' + t('usageRemaining') + ' ' + Math.round(account.quota?.remainingPercent != null ? Number(account.quota.remainingPercent) : (100 - Number(account.usagePercent))) + '%'
2983
+ ) : null,
2868
2984
  (account && account.quarantineUntil && Number(account.quarantineUntil) > Date.now()) ? h('div', { className: 'dsub-row', style: { marginTop: 4 } },
2869
2985
  h('span', { className: 'dsub-sub', style: { color: 'var(--dsw-alias-state-error-primary)', fontVariantNumeric: 'tabular-nums' } },
2870
2986
  '⛔ ' + (account.quarantineReason || 'QUARANTINE') + ' · ' + t('resetsIn') + ' ' + formatRelativeReset(account.quarantineUntil, t('lang'), Date.now())
@@ -3495,6 +3611,12 @@ h('div', { className: 'dsub-row' },
3495
3611
  type: 'button', className: 'dsub-save',
3496
3612
  onClick: () => save().catch((e) => setErr(cleanErrorMessage(e.message || e))),
3497
3613
  }, t('save')),
3614
+ h('button', {
3615
+ type: 'button', className: 'dsub-mini',
3616
+ disabled: !dirty,
3617
+ onClick: () => reload().catch((e) => setErr(cleanErrorMessage(e.message || e))),
3618
+ }, t('cancel')),
3619
+ dirty ? h('span', { className: 'dsub-sub' }, t('unsavedChanges')) : null,
3498
3620
  saved ? h('span', { className: 'dsub-ok' }, t('saved')) : null,
3499
3621
  info ? h('span', { className: 'dsub-ok' }, info) : null,
3500
3622
  err ? h('span', { className: 'dsub-bad' }, err) : null,
@@ -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`)
@@ -279,6 +279,17 @@ export function buildEnrichedCatalog({
279
279
 
280
280
  const modelName = (typeof item === 'object' && (item.name || item.display_name)) ? (item.name || item.display_name) : modelId
281
281
  const caps = inferModelCapabilities(provId, modelId)
282
+ const catalogSources = new Set()
283
+ for (const account of accounts) {
284
+ if (!account || String(account.provider || '').toLowerCase() !== String(provId).toLowerCase()) continue
285
+ const available = Array.isArray(account.availableModels) ? account.availableModels : []
286
+ const hasModel = available.some((model) => {
287
+ const id = typeof model === 'string' ? model : (model && (model.id || model.slug || model.name))
288
+ return String(id || '') === String(modelId)
289
+ })
290
+ if (hasModel) catalogSources.add(account.modelsSource || 'live')
291
+ }
292
+ if (!catalogSources.size) catalogSources.add('builtin')
282
293
 
283
294
  result.push({
284
295
  id: modelId,
@@ -288,6 +299,7 @@ export function buildEnrichedCatalog({
288
299
  contextWindow: caps.contextWindow,
289
300
  contextLabel: caps.contextLabel,
290
301
  capabilities: caps.capabilities,
302
+ catalogSources: Array.from(catalogSources),
291
303
  pool,
292
304
  })
293
305
  }
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 list = Array.isArray(accounts) ? accounts : []
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 isCooldown = pinned.cooldownUntil && Number(pinned.cooldownUntil) > now && cooldownBlocks(pinned, opts && opts.family)
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 isCooldown = acc.cooldownUntil && Number(acc.cooldownUntil) > now && cooldownBlocks(acc, opts && opts.family)
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({ ref, eligible: false, tier: null, reason: 'cooldown', until: acc.cooldownUntil })
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
- const chosen = tier[0]
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 {
@@ -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 || '')
@@ -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 (typeof deps.rememberCooldown === "function") deps.rememberCooldown(account.ref, account.cooldownUntil, cooled.cooldownFamilies || null)
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.29",
3
+ "version": "0.7.31",
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",