@goodandready/dsh-subscriptions 0.7.28 → 0.7.29

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.
@@ -5,6 +5,7 @@ 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,8 @@ 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),
41
44
  loadBlob: (ref) => store.loadBlob(ref),
42
45
  ensureFresh: (provider, blob, ref) => store.ensureFresh(provider, blob, ref),
43
46
  vendorConfig: (provider) => vendorConfig(provider, live()),
@@ -243,26 +246,28 @@ export function createAdapterManager({
243
246
  }
244
247
  }
245
248
 
246
- let lastModelRefresh = 0
247
- const providerModelCache = new Map()
249
+ const lastModelRefreshAt = new Map()
248
250
 
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) {
251
+ async function refreshModels({ force = false } = {}) {
252
+ const now = Date.now()
253
+ const slots = normalizeSlots(live().slots)
254
+ for (const slot of slots) {
255
+ const last = lastModelRefreshAt.get(slot.ref)
256
+ if (!force && catalogSnapshotFresh(last, now, MODEL_CATALOG_TTL_MS)) continue
254
257
  try {
255
- const slot = normalizeSlots(live().slots).find((x) => x.provider === provider)
256
- if (!slot) continue
257
258
  const blob = await store.loadBlob(slot.ref).catch(() => null)
258
259
  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)
260
+ const fresh = await store.ensureFresh(slot.provider, blob, slot.ref)
261
+ const cfg = vendorConfig(slot.provider, live())
262
+ const liveModels = await getVendor(slot.provider).listModels(fresh, cfg, fetch).catch(() => null)
263
+ // #828: empty/broken live catalogs must not erase last-known-good.
262
264
  if (Array.isArray(liveModels) && liveModels.length) {
263
- providerModelCache.set(provider, liveModels)
265
+ if (typeof store.rememberDynamicModels === 'function') {
266
+ store.rememberDynamicModels(slot.ref, liveModels)
267
+ }
268
+ lastModelRefreshAt.set(slot.ref, now)
264
269
  }
265
- } catch { /* best-effort */ }
270
+ } catch { /* keep the previous snapshot */ }
266
271
  }
267
272
  }
268
273
 
@@ -326,9 +331,22 @@ export function createAdapterManager({
326
331
  }
327
332
  eager()
328
333
 
334
+ // #799: refresh at startup and hourly. Per-ref snapshots younger than
335
+ // five minutes are skipped, and failures keep last-known-good.
336
+ refreshModels().catch((err) => {
337
+ if (logger && logger.debug) logger.debug('[dsh-subscriptions] model refresh error: ' + (err && err.message || err))
338
+ })
339
+ const modelTimer = setInterval(() => {
340
+ refreshModels().catch((err) => {
341
+ if (logger && logger.debug) logger.debug('[dsh-subscriptions] hourly model refresh error: ' + (err && err.message || err))
342
+ })
343
+ }, 60 * 60 * 1000)
344
+ if (modelTimer && typeof modelTimer.unref === 'function') modelTimer.unref()
345
+
329
346
  return () => {
330
347
  adapterDisposed = true
331
348
  if (retryTimer) clearTimeout(retryTimer)
349
+ clearInterval(modelTimer)
332
350
  if (handle) {
333
351
  disposeAdapterHandle(handle)
334
352
  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) || '')))
package/lib/client.js CHANGED
@@ -292,6 +292,9 @@ window.__ModuleLoader__.load({
292
292
  "poolModelsNone": "None",
293
293
  "poolModelsSelected": "selected",
294
294
  "poolModelsHint": "Automatic allows every current and future model. None removes this account from model routing. An explicit list permits only checked model ids.",
295
+ "poolModelsSearch": "Search models…",
296
+ "poolModelsCount": "{shown} shown · {selected} selected · {total} total",
297
+ "poolModelsNoMatches": "No models match this search.",
295
298
  "poolModelsNoCatalog": "No model catalog yet; sync models or use Automatic.",
296
299
  "tags": "Tags",
297
300
  "tagsPlaceholder": "team, prod",
@@ -675,6 +678,9 @@ window.__ModuleLoader__.load({
675
678
  "poolModelsNone": "无",
676
679
  "poolModelsSelected": "已选",
677
680
  "poolModelsHint": "自动允许当前及未来全部模型。无会将该账户移出模型调度。显式列表只允许勾选的模型 ID。",
681
+ "poolModelsSearch": "搜索模型…",
682
+ "poolModelsCount": "显示 {shown} · 已选 {selected} · 共 {total}",
683
+ "poolModelsNoMatches": "没有匹配的模型。",
678
684
  "poolModelsNoCatalog": "暂无模型目录;请同步模型或使用自动。",
679
685
  "tags": "标签",
680
686
  "tagsPlaceholder": "team, prod",
@@ -1047,6 +1053,7 @@ window.__ModuleLoader__.load({
1047
1053
  const [modelQuery, setModelQuery] = React.useState('')
1048
1054
  const [modelSort, setModelSort] = React.useState('name')
1049
1055
  const [modelLimit, setModelLimit] = React.useState(40)
1056
+ const [poolModelQuery, setPoolModelQuery] = React.useState({})
1050
1057
  const [paste, setPaste] = React.useState({})
1051
1058
  const [device, setDevice] = React.useState({}) // #90 key -> {state,userCode,authUrl,intervalMs,status}
1052
1059
  const [loginLinks, setLoginLinks] = React.useState({}) // #595 provider:index -> fallback auth URL when a popup is blocked
@@ -2753,6 +2760,14 @@ h('div', { className: 'dsub-row' },
2753
2760
  .concat(poolModels || [])
2754
2761
  .filter(Boolean)
2755
2762
  )).sort()
2763
+ const poolQuery = String(poolModelQuery[key] || '').trim().toLowerCase()
2764
+ const visiblePoolModelIds = poolQuery
2765
+ ? poolModelIds.filter((id) => String(id).toLowerCase().includes(poolQuery))
2766
+ : poolModelIds
2767
+ const selectedPoolCount = poolModels ? poolModels.length : poolModelIds.length
2768
+ const poolModeLabel = !poolModels
2769
+ ? t('poolModelsAutomatic')
2770
+ : (poolModels.length ? (poolModels.length + ' ' + t('poolModelsSelected')) : t('poolModelsNone'))
2756
2771
  const avatarSrc = safeExternalUrl(account.avatarUrl, true)
2757
2772
  const avatarEl = (avatarSrc ? h('img', {
2758
2773
  src: avatarSrc,
@@ -3063,9 +3078,24 @@ h('div', { className: 'dsub-row' },
3063
3078
  ),
3064
3079
  h('details', { className: 'dsub-details' },
3065
3080
  h('summary', { className: 'dsub-summary' },
3066
- '🎯 ' + t('poolModelsTitle') + ' · ' + (poolModels ? (poolModels.length + ' ' + t('poolModelsSelected')) : t('poolModelsAutomatic'))
3081
+ '🎯 ' + t('poolModelsTitle') + ' · ' + poolModeLabel
3067
3082
  ),
3068
3083
  h('div', { className: 'dsub-sub' }, t('poolModelsHint')),
3084
+ h('div', { className: 'dsub-row' },
3085
+ h('input', {
3086
+ className: 'dsub-inp dsub-grow',
3087
+ value: poolModelQuery[key] || '',
3088
+ placeholder: t('poolModelsSearch'),
3089
+ 'aria-label': t('poolModelsSearch'),
3090
+ onChange: (e) => setPoolModelQuery((q) => Object.assign({}, q, { [key]: e.target.value })),
3091
+ }),
3092
+ ),
3093
+ h('div', { className: 'dsub-sub' },
3094
+ t('poolModelsCount')
3095
+ .replace('{shown}', String(visiblePoolModelIds.length))
3096
+ .replace('{selected}', String(selectedPoolCount))
3097
+ .replace('{total}', String(poolModelIds.length))
3098
+ ),
3069
3099
  h('div', { className: 'dsub-row' },
3070
3100
  h('button', {
3071
3101
  type: 'button', className: 'dsub-mini' + (!poolModels ? ' dsub-btnActive' : ''),
@@ -3073,15 +3103,20 @@ h('div', { className: 'dsub-row' },
3073
3103
  }, t('poolModelsAutomatic')),
3074
3104
  h('button', {
3075
3105
  type: 'button', className: 'dsub-mini',
3076
- onClick: () => updateSlot(row.slot.provider, row.slot.index, { poolModels: poolModelIds.slice() }),
3106
+ onClick: () => updateSlot(row.slot.provider, row.slot.index, { poolModels: visiblePoolModelIds.slice() }),
3077
3107
  }, t('poolModelsAll')),
3078
3108
  h('button', {
3079
3109
  type: 'button', className: 'dsub-mini' + (poolModels && poolModels.length === 0 ? ' dsub-btnActive' : ''),
3080
- onClick: () => updateSlot(row.slot.provider, row.slot.index, { poolModels: [] }),
3110
+ onClick: () => {
3111
+ const base = poolModels || poolModelIds
3112
+ updateSlot(row.slot.provider, row.slot.index, {
3113
+ poolModels: base.filter((id) => !visiblePoolModelIds.includes(id)),
3114
+ })
3115
+ },
3081
3116
  }, t('poolModelsNone')),
3082
3117
  ),
3083
- poolModelIds.length ? h('div', { className: 'dsub-row', style: { flexWrap: 'wrap', gap: 6 } },
3084
- poolModelIds.map((modelId) => {
3118
+ visiblePoolModelIds.length ? h('div', { className: 'dsub-row', style: { flexWrap: 'wrap', gap: 6 } },
3119
+ visiblePoolModelIds.map((modelId) => {
3085
3120
  const checked = poolModels ? poolModels.includes(modelId) : true
3086
3121
  return h('label', { key: modelId, className: 'dsub-ref-tag', title: modelId },
3087
3122
  h('input', {
@@ -3103,7 +3138,7 @@ h('div', { className: 'dsub-row' },
3103
3138
  h('code', null, modelId)
3104
3139
  )
3105
3140
  })
3106
- ) : h('div', { className: 'dsub-sub' }, t('poolModelsNoCatalog')),
3141
+ ) : h('div', { className: 'dsub-sub' }, poolModelIds.length ? t('poolModelsNoMatches') : t('poolModelsNoCatalog')),
3107
3142
  ),
3108
3143
  h('div', { className: 'dsub-row' },
3109
3144
  h('span', { className: 'dsub-ref-tag' }, t('tags')),
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.7.28",
3
+ "version": "0.7.29",
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",