@goodandready/dsh-subscriptions 0.7.28 → 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.
@@ -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,10 +1,11 @@
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'
7
7
  import { Config, plainConfig } from './config-schema.js'
8
+ import { MODEL_CATALOG_TTL_MS, catalogSnapshotFresh } from './models-catalog.js'
8
9
 
9
10
  export function createAdapterManager({
10
11
  ctx,
@@ -38,6 +39,10 @@ export function createAdapterManager({
38
39
  const adapter = new SubscriptionAdapter({
39
40
  live: () => live(),
40
41
  listAccounts: (provider) => store.listAccounts(provider),
42
+ getDynamicModels: (ref) => store.getDynamicModels(ref),
43
+ rememberDynamicModels: (ref, models) => store.rememberDynamicModels(ref, models),
44
+ poolEnabled: () => live().poolEnabled !== false,
45
+ defaultAccountRef: (provider) => defaultAccountRef(live().slots, provider),
41
46
  loadBlob: (ref) => store.loadBlob(ref),
42
47
  ensureFresh: (provider, blob, ref) => store.ensureFresh(provider, blob, ref),
43
48
  vendorConfig: (provider) => vendorConfig(provider, live()),
@@ -243,26 +248,28 @@ export function createAdapterManager({
243
248
  }
244
249
  }
245
250
 
246
- let lastModelRefresh = 0
247
- const providerModelCache = new Map()
251
+ const lastModelRefreshAt = new Map()
248
252
 
249
- async function refreshModels() {
250
- if (Date.now() - lastModelRefresh < 60 * 1000) return
251
- lastModelRefresh = Date.now()
252
- const providers = await store.loggedInProviders()
253
- for (const provider of providers) {
253
+ async function refreshModels({ force = false } = {}) {
254
+ const now = Date.now()
255
+ const slots = normalizeSlots(live().slots)
256
+ for (const slot of slots) {
257
+ const last = lastModelRefreshAt.get(slot.ref)
258
+ if (!force && catalogSnapshotFresh(last, now, MODEL_CATALOG_TTL_MS)) continue
254
259
  try {
255
- const slot = normalizeSlots(live().slots).find((x) => x.provider === provider)
256
- if (!slot) continue
257
260
  const blob = await store.loadBlob(slot.ref).catch(() => null)
258
261
  if (!blob) continue
259
- const fresh = await store.ensureFresh(provider, blob, slot.ref)
260
- const cfg = vendorConfig(provider, live())
261
- const liveModels = await getVendor(provider).listModels(fresh, cfg, fetch).catch(() => null)
262
+ const fresh = await store.ensureFresh(slot.provider, blob, slot.ref)
263
+ const cfg = vendorConfig(slot.provider, live())
264
+ const liveModels = await getVendor(slot.provider).listModels(fresh, cfg, fetch).catch(() => null)
265
+ // #828: empty/broken live catalogs must not erase last-known-good.
262
266
  if (Array.isArray(liveModels) && liveModels.length) {
263
- providerModelCache.set(provider, liveModels)
267
+ if (typeof store.rememberDynamicModels === 'function') {
268
+ store.rememberDynamicModels(slot.ref, liveModels)
269
+ }
270
+ lastModelRefreshAt.set(slot.ref, now)
264
271
  }
265
- } catch { /* best-effort */ }
272
+ } catch { /* keep the previous snapshot */ }
266
273
  }
267
274
  }
268
275
 
@@ -326,9 +333,22 @@ export function createAdapterManager({
326
333
  }
327
334
  eager()
328
335
 
336
+ // #799: refresh at startup and hourly. Per-ref snapshots younger than
337
+ // five minutes are skipped, and failures keep last-known-good.
338
+ refreshModels().catch((err) => {
339
+ if (logger && logger.debug) logger.debug('[dsh-subscriptions] model refresh error: ' + (err && err.message || err))
340
+ })
341
+ const modelTimer = setInterval(() => {
342
+ refreshModels().catch((err) => {
343
+ if (logger && logger.debug) logger.debug('[dsh-subscriptions] hourly model refresh error: ' + (err && err.message || err))
344
+ })
345
+ }, 60 * 60 * 1000)
346
+ if (modelTimer && typeof modelTimer.unref === 'function') modelTimer.unref()
347
+
329
348
  return () => {
330
349
  adapterDisposed = true
331
350
  if (retryTimer) clearTimeout(retryTimer)
351
+ clearInterval(modelTimer)
332
352
  if (handle) {
333
353
  disposeAdapterHandle(handle)
334
354
  handle = undefined
package/lib/adapter.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { resolveEffectiveReasoning, getModelReasoningCapabilities } from './reasoning-effort.js'
2
- import { inferModelCapabilities, isDeprecatedId } from './models-catalog.js'
2
+ import { inferModelCapabilities, isDeprecatedId, mergeAccountCatalogs, resolveCatalogSource } from './models-catalog.js'
3
3
  import { fetchWithTimeout } from "./http.js"
4
4
  import { LlmAdapter, LlmError, attributionHeaders } from '@deepseek-ai/dsh-llm'
5
5
  import { displayName } from './refs.js'
@@ -54,10 +54,12 @@ export class SubscriptionAdapter extends LlmAdapter {
54
54
 
55
55
  async listModels(provider) {
56
56
  const accounts = await this.deps.listAccounts(provider)
57
+ const cfg = this.deps.vendorConfig(provider)
58
+ const builtin = modelCatalog(provider, cfg ? cfg.models : undefined)
57
59
  const targetAccount = accounts.find((a) => a.hasToken)
58
- let models = null
60
+ let targetLive = null
61
+
59
62
  if (targetAccount) {
60
- const cfg = this.deps.vendorConfig(provider)
61
63
  const baseFetch = pickFetch(this.deps, targetAccount.ref)
62
64
  const listModelsFetch = (url, init, opts) =>
63
65
  fetchWithTimeout(baseFetch, url, init, { timeoutMs: 15000, ...(opts || {}) })
@@ -68,13 +70,39 @@ export class SubscriptionAdapter extends LlmAdapter {
68
70
  targetAccount.ref,
69
71
  )
70
72
  const live = await getVendor(provider).listModels(blob, cfg, listModelsFetch)
71
- if (Array.isArray(live) && live.length) models = live
72
- } catch { /* use built-in catalog */ }
73
+ if (Array.isArray(live) && live.length) {
74
+ targetLive = live
75
+ if (typeof this.deps.rememberDynamicModels === 'function') {
76
+ this.deps.rememberDynamicModels(targetAccount.ref, live)
77
+ }
78
+ }
79
+ } catch { /* #828: keep last-known-good / built-in catalog */ }
73
80
  }
74
- if (!models) {
75
- const cfg = this.deps.vendorConfig(provider)
76
- models = modelCatalog(provider, cfg ? cfg.models : undefined)
81
+
82
+ // #797: build the picker union from each account's own catalog. Live data
83
+ // is only fetched for the primary account here; background refresh fills
84
+ // the rest without blocking the picker.
85
+ const entries = []
86
+ for (const account of accounts) {
87
+ if (!account || !account.hasToken) continue
88
+ const cached = typeof this.deps.getDynamicModels === 'function'
89
+ ? this.deps.getDynamicModels(account.ref)
90
+ : null
91
+ const resolved = resolveCatalogSource({
92
+ live: account === targetAccount ? targetLive : null,
93
+ cached,
94
+ builtin,
95
+ })
96
+ entries.push({
97
+ ref: account.ref,
98
+ label: account.label || account.email || account.ref,
99
+ models: resolved.models,
100
+ source: resolved.source,
101
+ })
77
102
  }
103
+ if (!entries.length) entries.push({ models: builtin, source: 'builtin' })
104
+
105
+ let models = mergeAccountCatalogs(entries)
78
106
  // #94: optionally hide test/preview/beta/legacy ids from the picker.
79
107
  if (typeof this.deps.hideDeprecatedModels === 'function' && this.deps.hideDeprecatedModels()) {
80
108
  models = models.filter((m) => !isDeprecatedId(String((m && m.id) || '')))
@@ -165,11 +193,18 @@ export class SubscriptionAdapter extends LlmAdapter {
165
193
  options.signal,
166
194
  ),
167
195
  }
196
+ const poolEnabled = typeof deps.poolEnabled === 'function' ? deps.poolEnabled() !== false : true
197
+ const defaultRef = typeof deps.defaultAccountRef === 'function' ? deps.defaultAccountRef(provider) : null
168
198
  const streamOptions = {
169
199
  ...hydratedOptions,
170
200
  ...(provider === 'codex' && getSpeedMode(sessionId) === 'fast' ? { service_tier: 'priority' } : {}),
171
201
  ...(effectiveReasoning.effort ? { reasoningEffort: effectiveReasoning.effort } : {}),
172
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 } : {}),
173
208
  _decisionTrace: decisionTrace,
174
209
  autoPacing: effectiveAutoPacing,
175
210
  drainBeforeReset: effectiveDrainBeforeReset,
@@ -188,7 +223,7 @@ export class SubscriptionAdapter extends LlmAdapter {
188
223
  webhookUrl: typeof deps.webhookUrl === 'function' ? deps.webhookUrl() : deps.webhookUrl,
189
224
  fetchImpl: deps.fetchImpl || fetch,
190
225
  onCooldown: (account) => {
191
- 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)
192
227
  if (typeof deps.rememberQuarantine === 'function') {
193
228
  const currentQ = typeof deps.getQuarantine === 'function' ? deps.getQuarantine(account.ref) : null
194
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)",
@@ -292,6 +298,9 @@ window.__ModuleLoader__.load({
292
298
  "poolModelsNone": "None",
293
299
  "poolModelsSelected": "selected",
294
300
  "poolModelsHint": "Automatic allows every current and future model. None removes this account from model routing. An explicit list permits only checked model ids.",
301
+ "poolModelsSearch": "Search models…",
302
+ "poolModelsCount": "{shown} shown · {selected} selected · {total} total",
303
+ "poolModelsNoMatches": "No models match this search.",
295
304
  "poolModelsNoCatalog": "No model catalog yet; sync models or use Automatic.",
296
305
  "tags": "Tags",
297
306
  "tagsPlaceholder": "team, prod",
@@ -541,6 +550,12 @@ window.__ModuleLoader__.load({
541
550
  "cascadingFallbackOrderHint": "首选服务商耗尽时按此顺序依次尝试备选服务商。",
542
551
  "rotationStrategy": "账户负载均衡策略",
543
552
  "rotationStrategyHint": "在同一服务商的多个可用账户之间分发请求的方式。",
553
+ "rotationQuotaAware": "配额感知(优先紧急)",
554
+ "poolEnabled": "自动账户池",
555
+ "poolEnabledHint": "关闭后请求只使用服务商默认账户,不会静默回退到其他账户。",
556
+ "mediaPoolEnabled": "自动媒体池",
557
+ "mediaPoolEnabledHint": "独立控制图片/视频账户轮询,与文本池分开。",
558
+ "defaultAccountHint": "用于直接路由,以及关闭自动账户池时的请求。",
544
559
  "rotationRoundRobin": "轮询均衡(标准交替)",
545
560
  "rotationLeastLoaded": "最少负载优先(配额公平共享)",
546
561
  "rotationPriority": "优先级顺序(按序号/权重顺序)",
@@ -675,6 +690,9 @@ window.__ModuleLoader__.load({
675
690
  "poolModelsNone": "无",
676
691
  "poolModelsSelected": "已选",
677
692
  "poolModelsHint": "自动允许当前及未来全部模型。无会将该账户移出模型调度。显式列表只允许勾选的模型 ID。",
693
+ "poolModelsSearch": "搜索模型…",
694
+ "poolModelsCount": "显示 {shown} · 已选 {selected} · 共 {total}",
695
+ "poolModelsNoMatches": "没有匹配的模型。",
678
696
  "poolModelsNoCatalog": "暂无模型目录;请同步模型或使用自动。",
679
697
  "tags": "标签",
680
698
  "tagsPlaceholder": "team, prod",
@@ -1047,6 +1065,7 @@ window.__ModuleLoader__.load({
1047
1065
  const [modelQuery, setModelQuery] = React.useState('')
1048
1066
  const [modelSort, setModelSort] = React.useState('name')
1049
1067
  const [modelLimit, setModelLimit] = React.useState(40)
1068
+ const [poolModelQuery, setPoolModelQuery] = React.useState({})
1050
1069
  const [paste, setPaste] = React.useState({})
1051
1070
  const [device, setDevice] = React.useState({}) // #90 key -> {state,userCode,authUrl,intervalMs,status}
1052
1071
  const [loginLinks, setLoginLinks] = React.useState({}) // #595 provider:index -> fallback auth URL when a popup is blocked
@@ -2482,10 +2501,33 @@ h('div', { className: 'dsub-row' },
2482
2501
  },
2483
2502
  h('option', { value: 'round-robin' }, t('rotationRoundRobin')),
2484
2503
  h('option', { value: 'least-loaded' }, t('rotationLeastLoaded')),
2504
+ h('option', { value: 'quota-aware' }, t('rotationQuotaAware')),
2485
2505
  h('option', { value: 'priority' }, t('rotationPriority')),
2486
2506
  ),
2487
2507
  ),
2488
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
+ ),
2489
2531
  h('div', { className: 'dsub-row', style: { marginTop: 12 } },
2490
2532
  h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' } },
2491
2533
  h('input', {
@@ -2753,6 +2795,14 @@ h('div', { className: 'dsub-row' },
2753
2795
  .concat(poolModels || [])
2754
2796
  .filter(Boolean)
2755
2797
  )).sort()
2798
+ const poolQuery = String(poolModelQuery[key] || '').trim().toLowerCase()
2799
+ const visiblePoolModelIds = poolQuery
2800
+ ? poolModelIds.filter((id) => String(id).toLowerCase().includes(poolQuery))
2801
+ : poolModelIds
2802
+ const selectedPoolCount = poolModels ? poolModels.length : poolModelIds.length
2803
+ const poolModeLabel = !poolModels
2804
+ ? t('poolModelsAutomatic')
2805
+ : (poolModels.length ? (poolModels.length + ' ' + t('poolModelsSelected')) : t('poolModelsNone'))
2756
2806
  const avatarSrc = safeExternalUrl(account.avatarUrl, true)
2757
2807
  const avatarEl = (avatarSrc ? h('img', {
2758
2808
  src: avatarSrc,
@@ -2766,6 +2816,14 @@ h('div', { className: 'dsub-row' },
2766
2816
  return h('div', { className: 'dsub-card', key: key },
2767
2817
  h('div', { className: 'dsub-row', style: { alignItems: 'center' } },
2768
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
+ ),
2769
2827
  h('input', {
2770
2828
  className: 'dsub-grow',
2771
2829
  value: row.slot.label || '',
@@ -3063,9 +3121,24 @@ h('div', { className: 'dsub-row' },
3063
3121
  ),
3064
3122
  h('details', { className: 'dsub-details' },
3065
3123
  h('summary', { className: 'dsub-summary' },
3066
- '🎯 ' + t('poolModelsTitle') + ' · ' + (poolModels ? (poolModels.length + ' ' + t('poolModelsSelected')) : t('poolModelsAutomatic'))
3124
+ '🎯 ' + t('poolModelsTitle') + ' · ' + poolModeLabel
3067
3125
  ),
3068
3126
  h('div', { className: 'dsub-sub' }, t('poolModelsHint')),
3127
+ h('div', { className: 'dsub-row' },
3128
+ h('input', {
3129
+ className: 'dsub-inp dsub-grow',
3130
+ value: poolModelQuery[key] || '',
3131
+ placeholder: t('poolModelsSearch'),
3132
+ 'aria-label': t('poolModelsSearch'),
3133
+ onChange: (e) => setPoolModelQuery((q) => Object.assign({}, q, { [key]: e.target.value })),
3134
+ }),
3135
+ ),
3136
+ h('div', { className: 'dsub-sub' },
3137
+ t('poolModelsCount')
3138
+ .replace('{shown}', String(visiblePoolModelIds.length))
3139
+ .replace('{selected}', String(selectedPoolCount))
3140
+ .replace('{total}', String(poolModelIds.length))
3141
+ ),
3069
3142
  h('div', { className: 'dsub-row' },
3070
3143
  h('button', {
3071
3144
  type: 'button', className: 'dsub-mini' + (!poolModels ? ' dsub-btnActive' : ''),
@@ -3073,15 +3146,20 @@ h('div', { className: 'dsub-row' },
3073
3146
  }, t('poolModelsAutomatic')),
3074
3147
  h('button', {
3075
3148
  type: 'button', className: 'dsub-mini',
3076
- onClick: () => updateSlot(row.slot.provider, row.slot.index, { poolModels: poolModelIds.slice() }),
3149
+ onClick: () => updateSlot(row.slot.provider, row.slot.index, { poolModels: visiblePoolModelIds.slice() }),
3077
3150
  }, t('poolModelsAll')),
3078
3151
  h('button', {
3079
3152
  type: 'button', className: 'dsub-mini' + (poolModels && poolModels.length === 0 ? ' dsub-btnActive' : ''),
3080
- onClick: () => updateSlot(row.slot.provider, row.slot.index, { poolModels: [] }),
3153
+ onClick: () => {
3154
+ const base = poolModels || poolModelIds
3155
+ updateSlot(row.slot.provider, row.slot.index, {
3156
+ poolModels: base.filter((id) => !visiblePoolModelIds.includes(id)),
3157
+ })
3158
+ },
3081
3159
  }, t('poolModelsNone')),
3082
3160
  ),
3083
- poolModelIds.length ? h('div', { className: 'dsub-row', style: { flexWrap: 'wrap', gap: 6 } },
3084
- poolModelIds.map((modelId) => {
3161
+ visiblePoolModelIds.length ? h('div', { className: 'dsub-row', style: { flexWrap: 'wrap', gap: 6 } },
3162
+ visiblePoolModelIds.map((modelId) => {
3085
3163
  const checked = poolModels ? poolModels.includes(modelId) : true
3086
3164
  return h('label', { key: modelId, className: 'dsub-ref-tag', title: modelId },
3087
3165
  h('input', {
@@ -3103,7 +3181,7 @@ h('div', { className: 'dsub-row' },
3103
3181
  h('code', null, modelId)
3104
3182
  )
3105
3183
  })
3106
- ) : h('div', { className: 'dsub-sub' }, t('poolModelsNoCatalog')),
3184
+ ) : h('div', { className: 'dsub-sub' }, poolModelIds.length ? t('poolModelsNoMatches') : t('poolModelsNoCatalog')),
3107
3185
  ),
3108
3186
  h('div', { className: 'dsub-row' },
3109
3187
  h('span', { className: 'dsub-ref-tag' }, t('tags')),
@@ -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`)
@@ -6,6 +6,63 @@ export function isDeprecatedId(id) {
6
6
  import { getVendor } from './vendors/index.js'
7
7
  import { getAccountRemainingPercent } from './rotate.js'
8
8
 
9
+ export const MODEL_CATALOG_TTL_MS = 5 * 60 * 1000
10
+
11
+ function catalogList(value) {
12
+ return Array.isArray(value) ? value.filter(Boolean) : []
13
+ }
14
+
15
+ /**
16
+ * Pick the best available catalog without ever dropping a provider.
17
+ * live > last-known-good cache > built-in vendor catalog.
18
+ */
19
+ export function resolveCatalogSource({ live, cached, builtin } = {}) {
20
+ const liveList = catalogList(live)
21
+ if (liveList.length) return { models: liveList, source: 'live' }
22
+ const cachedList = catalogList(cached)
23
+ if (cachedList.length) return { models: cachedList, source: 'cached' }
24
+ return { models: catalogList(builtin), source: 'builtin' }
25
+ }
26
+
27
+ export function catalogSnapshotFresh(at, nowMs = Date.now(), ttlMs = MODEL_CATALOG_TTL_MS) {
28
+ if (!at || !Number.isFinite(Number(at))) return false
29
+ return Number(nowMs) - Number(at) < Number(ttlMs)
30
+ }
31
+
32
+ /**
33
+ * Union per-account catalogs, keeping the accounts that can see each model.
34
+ * Rows are deduplicated by id and never mutated.
35
+ */
36
+ export function mergeAccountCatalogs(entries = []) {
37
+ const byId = new Map()
38
+ for (const entry of Array.isArray(entries) ? entries : []) {
39
+ if (!entry) continue
40
+ for (const model of catalogList(entry.models)) {
41
+ const id = model && (model.id || model.slug || model.name)
42
+ if (!id) continue
43
+ const existing = byId.get(id)
44
+ if (existing) {
45
+ if (entry.ref && !existing.accounts.includes(entry.ref)) existing.accounts.push(entry.ref)
46
+ if (entry.label && !existing.accountLabels.includes(entry.label)) existing.accountLabels.push(entry.label)
47
+ if (entry.source && !existing.catalogSources.includes(entry.source)) existing.catalogSources.push(entry.source)
48
+ continue
49
+ }
50
+ byId.set(id, {
51
+ ...model,
52
+ accounts: entry.ref ? [entry.ref] : [],
53
+ accountLabels: entry.label ? [entry.label] : [],
54
+ catalogSources: entry.source ? [entry.source] : [],
55
+ })
56
+ }
57
+ }
58
+ return Array.from(byId.values())
59
+ }
60
+
61
+ export function modelAllowlistMode(poolModels) {
62
+ if (!Array.isArray(poolModels)) return 'all'
63
+ return poolModels.length ? 'selected' : 'none'
64
+ }
65
+
9
66
  export const CAPABILITY_TAGS = {
10
67
  VISION: 'vision',
11
68
  TOOLS: 'tools',
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.28",
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",