@goodandready/dsh-subscriptions 0.6.33 → 0.6.35

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.
@@ -4,7 +4,7 @@ import { 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
- import { Config } from './config-schema.js'
7
+ import { Config, plainConfig } from './config-schema.js'
8
8
 
9
9
  export function createAdapterManager({
10
10
  ctx,
@@ -187,7 +187,7 @@ export function createAdapterManager({
187
187
  }
188
188
  const currentSlots = Array.isArray(live().slots) ? live().slots : []
189
189
  const newSlots = [...currentSlots, ...additions]
190
- const parsed = Config({ ...live(), slots: stripLegacySlots(newSlots) })
190
+ const parsed = plainConfig(Config({ ...live(), slots: stripLegacySlots(newSlots) }))
191
191
  await api.replace(parsed)
192
192
  syncCustomVendors()
193
193
  await syncAdapter()
@@ -16,68 +16,77 @@ export const Slot = z.object({
16
16
 
17
17
  export const defaultSlots = PROVIDERS.map((provider) => ({ provider, index: 1, label: '' }))
18
18
 
19
+ // Unwrap Volatile boxes before any caller reads a value. A volatile field holds a box
20
+ // rather than its value, and the Loader mutates those boxes in place.
21
+ export function plainConfig(value) {
22
+ if (value === null || typeof value !== 'object') return value
23
+ if (Array.isArray(value)) return value.map(plainConfig)
24
+ if (typeof value.get === 'function') return plainConfig(value.get())
25
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, plainConfig(v)]))
26
+ }
27
+
19
28
  export const Config = z.object({
20
- cooldownMs: z.number().default(30 * 60 * 1000)
29
+ cooldownMs: z.number().volatile().default(30 * 60 * 1000)
21
30
  .description('After RATE_LIMIT/QUOTA/429, skip that account for this many milliseconds.'),
22
- switchAtRemaining: z.number().default(0.01)
31
+ switchAtRemaining: z.number().volatile().default(0.01)
23
32
  .description('If remaining <= this (absolute or <1 fraction), treat as exhausted before request. 0 disables.'),
24
- refreshAheadMs: z.number().default(5 * 60 * 1000)
33
+ refreshAheadMs: z.number().volatile().default(5 * 60 * 1000)
25
34
  .description('Background refresh when expiry within this many ms.'),
26
- refreshRetryMs: z.number().default(10 * 60 * 1000)
35
+ refreshRetryMs: z.number().volatile().default(10 * 60 * 1000)
27
36
  .description('Do not retry background refresh more often than this after failure.'),
28
- probeIntervalMin: z.number().default(15)
37
+ probeIntervalMin: z.number().volatile().default(15)
29
38
  .description('Background health-check interval in minutes. 0 disables.'),
30
- notifyLimits: z.boolean().default(true)
39
+ notifyLimits: z.boolean().volatile().default(true)
31
40
  .description('Emit log notices when usage crosses 70/90/100% of a window.'),
32
- expiryNotifyDays: z.number().default(7)
41
+ expiryNotifyDays: z.number().volatile().default(7)
33
42
  .description('#67 Warn in the header chip this many days before a subscription expiry (expiresAt). 0 disables.'),
34
- privacyMask: z.boolean().default(false)
43
+ privacyMask: z.boolean().volatile().default(false)
35
44
  .description('#98 Hide personal data in the UI: emails show as j***n@example.com.'),
36
- slots: z.array(Slot).default(defaultSlots)
45
+ slots: z.array(Slot).volatile().default(defaultSlots)
37
46
  .description('Account slots. Secrets are not stored here; only the credential ref names.'),
38
- useWebCallback: z.boolean().default(false)
47
+ useWebCallback: z.boolean().volatile().default(false)
39
48
  .description('When on, redirect_uri is this Web UI origin + /dsh-subscriptions/oauth/callback. When off, the vendor CLI registered redirect is used and you paste the redirected URL.'),
40
- autoLoopback: z.boolean().default(true)
49
+ autoLoopback: z.boolean().volatile().default(true)
41
50
  .description('#89 When on and the vendor redirect_uri is a loopback address (codex :1455, grok :56121), a temporary local server catches the OAuth callback automatically - no paste needed. Paste fallback stays available.'),
42
- ollamaBaseUrl: z.string().default('http://127.0.0.1:11434')
51
+ ollamaBaseUrl: z.string().volatile().default('http://127.0.0.1:11434')
43
52
  .description('#91 Local Ollama base URL. Served as the ollama provider in the native model picker when reachable.'),
44
- ollamaFallback: z.boolean().default(true)
53
+ ollamaFallback: z.boolean().volatile().default(true)
45
54
  .description('#91 When every account of a provider is exhausted, continue the chat on local Ollama instead of failing.'),
46
- ollamaFallbackModel: z.string().default('')
55
+ ollamaFallbackModel: z.string().volatile().default('')
47
56
  .description('#91 Ollama model used for the fallback (for example qwen2.5-coder). Empty = first model from /api/tags.'),
48
- hideDeprecatedModels: z.boolean().default(false)
57
+ hideDeprecatedModels: z.boolean().volatile().default(false)
49
58
  .description('#94 Hide test/preview/beta/legacy model ids from the native model picker.'),
50
- codexClientId: z.string().default(''),
51
- codexVerbosity: z.string().default('')
59
+ codexClientId: z.string().volatile().default(''),
60
+ codexVerbosity: z.string().volatile().default('')
52
61
  .description('#93 Response verbosity for Codex reasoning models: low, medium or high. Empty = protocol default.'),
53
- codexFastMode: z.boolean().default(false)
62
+ codexFastMode: z.boolean().volatile().default(false)
54
63
  .description('#92 Fast Mode for Codex: sends service_tier priority (1.5x speed billing tier) with every request.'),
55
- composerQuota: z.string().default('off')
64
+ composerQuota: z.string().volatile().default('off')
56
65
  .description('#84 Composer quota indicator mode: off, percent, bar or forecast (predictive runway from a sliding window).'),
57
- codexRedirectUri: z.string().default(''),
58
- codexBaseUrl: z.string().default(''),
59
- codexClientVersion: z.string().default('')
66
+ codexRedirectUri: z.string().volatile().default(''),
67
+ codexBaseUrl: z.string().volatile().default(''),
68
+ codexClientVersion: z.string().volatile().default('')
60
69
  .description('Codex CLI identity version for /models catalog gating (e.g. 0.157.1). Empty uses the built-in default.'),
61
- claudeClientId: z.string().default(''),
62
- claudeRedirectUri: z.string().default(''),
63
- grokClientId: z.string().default(''),
64
- grokRedirectUri: z.string().default(''),
65
- grokBaseUrl: z.string().default(''),
66
- grokClientVersion: z.string().default('')
70
+ claudeClientId: z.string().volatile().default(''),
71
+ claudeRedirectUri: z.string().volatile().default(''),
72
+ grokClientId: z.string().volatile().default(''),
73
+ grokRedirectUri: z.string().volatile().default(''),
74
+ grokBaseUrl: z.string().volatile().default(''),
75
+ grokClientVersion: z.string().volatile().default('')
67
76
  .description('Grok CLI identity version header. Empty uses the built-in default.'),
68
- antigravityClientId: z.string().default(''),
69
- antigravityRedirectUri: z.string().default(''),
70
- cascadingFallback: z.boolean().default(false)
77
+ antigravityClientId: z.string().volatile().default(''),
78
+ antigravityRedirectUri: z.string().volatile().default(''),
79
+ cascadingFallback: z.boolean().volatile().default(false)
71
80
  .description('#349 Opt-in cross-vendor fallback when all accounts of a provider are exhausted or rate limited.'),
72
- cascadingChain: z.any().default({})
81
+ cascadingChain: z.any().volatile().default({})
73
82
  .description('#349 Custom cross-vendor fallback chains (e.g. { claude: ["copilot", "cursor"] }).'),
74
- webhookUrl: z.string().default('')
83
+ webhookUrl: z.string().volatile().default('')
75
84
  .description('#351 External webhook URL to receive quota and session alerts (JSON POST).'),
76
- alertThresholds: z.array(z.number()).default([80, 90, 95])
85
+ alertThresholds: z.array(z.number()).volatile().default([80, 90, 95])
77
86
  .description('#351 Quota usage percentage thresholds for firing alerts.'),
78
- autoPacing: z.boolean().default(true)
87
+ autoPacing: z.boolean().volatile().default(true)
79
88
  .description('#352 Predictive burn-rate load balancing between accounts.'),
80
- customVendors: z.array(z.any()).default([])
89
+ customVendors: z.array(z.any()).volatile().default([])
81
90
  .description('Declarative OpenAI-Responses-compatible providers. See README.'),
82
91
  })
83
92
 
package/lib/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { plainConfig } from './config-schema.js'
2
+ export { plainConfig } from './config-schema.js'
1
3
  import { readFileSync } from 'node:fs'
2
4
  import { bestEffort } from './utils.js'
3
5
  export { Config, publicConfig, Slot, defaultSlots } from './config-schema.js'
@@ -66,9 +68,14 @@ const OK_HTML = `<!doctype html>
66
68
 
67
69
  export function apply(ctx, rawConfig) {
68
70
  const logger = typeof ctx.logger === 'function' ? ctx.logger('subscriptions') : (ctx.logger || undefined)
69
- let settingsApi
70
71
 
71
- let cachedConfig = Config(rawConfig || {})
72
+ // rawConfig still holds Volatile boxes, so unwrap before validating.
73
+ const resolveEntry = () => {
74
+ // rawConfig still holds Volatile boxes, so unwrap before validating; Config then
75
+ // re-creates boxes for the volatile fields, so unwrap the result as well.
76
+ return plainConfig(Config(plainConfig(rawConfig || {})))
77
+ }
78
+ let cachedConfig = resolveEntry()
72
79
  const live = () => cachedConfig
73
80
 
74
81
  function syncCustomVendors() {
@@ -95,65 +102,93 @@ export function apply(ctx, rawConfig) {
95
102
 
96
103
  const syncSnapshot = (next) => {
97
104
  try {
98
- const fresh = next !== undefined ? next : (typeof settingsApi?.get === 'function' ? settingsApi.get() : cachedConfig)
99
- cachedConfig = Config(fresh || {})
105
+ const fresh = next !== undefined ? next : resolveEntry()
106
+ cachedConfig = fresh
100
107
  syncCustomVendors()
101
108
  syncAdapter().catch(() => {})
102
109
  syncOllama().catch(() => {})
103
110
  } catch { /* keep existing snapshot on error */ }
104
111
  }
105
112
 
106
- if (typeof ctx.inject === 'function') {
107
- ctx.inject(['settings'], (sctx) => {
108
- const scope = sctx.settings.register(NS, Config, { base: rawConfig })
109
- settingsApi = {
110
- ...scope,
111
- get: () => (typeof scope.get === 'function' ? scope.get() : cachedConfig),
112
- async replace(next) {
113
- syncSnapshot(next)
114
- if (typeof scope.replace === 'function') {
115
- return await scope.replace(next)
116
- }
117
- },
118
- }
113
+ // settings.register was removed before DSH 0.1.7-rc.2, so the scope this plugin used
114
+ // to build from it never existed: getSettingsApi() returned null and writes only
115
+ // touched memory. Back it with the real settings service instead, whose
116
+ // describe()/update()/replace() are the contract both releases share.
117
+ let settingsSvc = null
119
118
 
120
- if (typeof scope.get === 'function') {
121
- syncSnapshot(scope.get())
122
- }
119
+ const describeRow = () => {
120
+ if (typeof settingsSvc?.describe !== 'function') return null
121
+ try {
122
+ return settingsSvc.describe().find((r) => r && r.ns === NS) || null
123
+ } catch (err) {
124
+ if (logger && logger.warn) logger.warn('[dsh-subscriptions] describe failed: ' + String(err && err.message || err))
125
+ return null
126
+ }
127
+ }
123
128
 
124
- if (typeof scope.watch === 'function') {
125
- try {
126
- const unwatch = scope.watch((next) => syncSnapshot(next))
127
- if (typeof unwatch === 'function') {
128
- sctx.effect(() => unwatch)
129
- }
130
- } catch { /* ignore watch error */ }
129
+ const settingsApi = {
130
+ get: () => live(),
131
+ replace: async (next) => {
132
+ const parsed = plainConfig(next)
133
+ cachedConfig = parsed
134
+ syncCustomVendors()
135
+ if (typeof settingsSvc?.update === 'function') {
136
+ await settingsSvc.update(NS, structuredClone(parsed), describeRow()?.revision)
137
+ } else if (typeof settingsSvc?.replace === 'function') {
138
+ await settingsSvc.replace(NS, structuredClone(parsed), describeRow()?.revision)
131
139
  }
140
+ return parsed
141
+ },
142
+ }
143
+ // Consumers (routes, adapter manager) call getSettingsApi().replace(config). The host
144
+ // service offers describe/update/replace with a revision, so adapt it here: the adapter
145
+ // keeps the cached snapshot current and persists through the real service.
146
+ const getSettingsApi = () => (settingsSvc ? settingsApi : null)
132
147
 
133
- Promise.resolve().then(() => {
134
- reconcileSlots().catch((err) => {
135
- if (logger && logger.warn) logger.warn('[dsh-subscriptions] reconcileSlots initial error: ' + (err && err.message || err))
136
- })
137
- })
138
-
139
- const onDocUpdated = (ns) => {
140
- if (!ns || ns === NS) {
141
- syncSnapshot()
142
- }
148
+ ctx.inject(['settings'], (sctx) => {
149
+ try {
150
+ settingsSvc = sctx.settings || (typeof sctx.get === 'function' ? sctx.get('settings') : null) || null
151
+ if (!settingsSvc) return
152
+ const served = describeRow()
153
+ if (served && served.value) {
154
+ try { cachedConfig = plainConfig(Config(plainConfig(served.value))) } catch { /* keep current */ }
143
155
  }
144
- if (typeof sctx.on === 'function') {
145
- sctx.on('settings/document-updated', onDocUpdated)
146
- } else if (typeof ctx.on === 'function') {
147
- ctx.on('settings/document-updated', onDocUpdated)
156
+ if (typeof sctx.effect === 'function') {
157
+ sctx.effect(() => () => { settingsSvc = null })
148
158
  }
159
+ } catch (err) {
160
+ // The profile may be mounted without the settings service; the static config stays
161
+ // authoritative and the volatile-update handler below still applies later changes.
162
+ if (logger && logger.warn) logger.warn('[dsh-subscriptions] settings service unavailable: ' + String(err && err.message || err))
163
+ }
164
+ })
149
165
 
150
- sctx.effect(() => () => {
151
- settingsApi = undefined
152
- cachedConfig = Config(rawConfig || {})
153
- syncCustomVendors()
154
- })
155
- })
166
+ // The host announces configuration changes with settings/document-updated; the Loader
167
+ // also re-announces the volatile boxes in place. Refresh the cached snapshot on both.
168
+ const onSettingsChanged = () => {
169
+ try {
170
+ const row = describeRow()
171
+ if (row && row.value) cachedConfig = plainConfig(Config(plainConfig(row.value)))
172
+ else syncSnapshot()
173
+ reconcileSlots().catch(() => {})
174
+ } catch { /* keep existing snapshot on error */ }
156
175
  }
176
+ if (typeof ctx.on === 'function') {
177
+ ctx.effect(() => {
178
+ ctx.on('settings/document-updated', onSettingsChanged)
179
+ ctx.on('loader/volatile-update', onSettingsChanged)
180
+ return () => {
181
+ ctx.off?.('settings/document-updated', onSettingsChanged)
182
+ ctx.off?.('loader/volatile-update', onSettingsChanged)
183
+ }
184
+ }, 'dsh-subscriptions: settings change listener')
185
+ }
186
+
187
+ Promise.resolve().then(() => {
188
+ reconcileSlots().catch((err) => {
189
+ if (logger && logger.warn) logger.warn('[dsh-subscriptions] reconcileSlots initial error: ' + (err && err.message || err))
190
+ })
191
+ })
157
192
 
158
193
  const fetchForRef = (ref) => {
159
194
  const parsed = parseOauthRef(ref)
@@ -250,7 +285,7 @@ export function apply(ctx, rawConfig) {
250
285
  fetch,
251
286
  fetchForRef,
252
287
  recordHistory,
253
- getSettingsApi: () => settingsApi,
288
+ getSettingsApi,
254
289
  syncCustomVendors,
255
290
  syncOllama,
256
291
  stripLegacySlots,
@@ -259,6 +294,7 @@ export function apply(ctx, rawConfig) {
259
294
  adapterManager.registerAdapterLifecycle()
260
295
 
261
296
  const subscriptions = createSubscriptionsService({
297
+ live,
262
298
  listAccounts: (provider) => store.listAccounts(provider),
263
299
  loadBlob: (ref) => store.loadBlob(ref),
264
300
  ensureFresh: (provider, blob, ref) => store.ensureFresh(provider, blob, ref),
@@ -437,7 +473,7 @@ export function apply(ctx, rawConfig) {
437
473
  NS,
438
474
  live,
439
475
  accountsView,
440
- getSettingsApi: () => settingsApi,
476
+ getSettingsApi,
441
477
  syncCustomVendors,
442
478
  syncAdapter,
443
479
  stripLegacySlots,
@@ -1,6 +1,6 @@
1
1
  import { exportVault, importVault } from '../vault.js'
2
2
  import { normalizeSlots } from '../accounts.js'
3
- import { Config, publicConfig } from '../config-schema.js'
3
+ import { Config, plainConfig, publicConfig } from '../config-schema.js'
4
4
  import { decryptWithPassphrase, encryptWithPassphrase } from '../crypto.js'
5
5
  import { isTrustedSettingsRequest, queryOf, readBody, safeJsonHandler, writeJson } from '../http.js'
6
6
  import { discoverLocalCliSessions, loadLocalCliBlob } from '../import-auth.js'
@@ -159,7 +159,7 @@ export function registerAccountsRoutes(ctx, state) {
159
159
  index: idx,
160
160
  label: blob.email || '',
161
161
  }])
162
- const parsed = Config({ ...live(), slots: stripLegacySlots(nextSlots) })
162
+ const parsed = plainConfig(Config({ ...live(), slots: stripLegacySlots(nextSlots) }))
163
163
  await getSettingsApi().replace(parsed)
164
164
  syncCustomVendors()
165
165
  }
@@ -424,7 +424,7 @@ export function registerAccountsRoutes(ctx, state) {
424
424
  curSlots.push(s)
425
425
  }
426
426
  }
427
- parsed = Config({ ...live(), slots: stripLegacySlots(curSlots) })
427
+ parsed = plainConfig(Config({ ...live(), slots: stripLegacySlots(curSlots) }))
428
428
  }
429
429
  let blobCount = 0
430
430
  for (const [ref, blob] of Object.entries(result.blobs)) {
@@ -1,6 +1,6 @@
1
1
  import { getRecentAlerts, clearAlerts } from '../alerts.js'
2
2
  import { normalizeSlots, vendorConfig } from '../accounts.js'
3
- import { Config, publicConfig } from '../config-schema.js'
3
+ import { Config, plainConfig, publicConfig } from '../config-schema.js'
4
4
  import { isTrustedSettingsRequest, queryOf, readBody, safeJsonHandler, writeJson } from '../http.js'
5
5
  import { quotaSnapshot } from '../ratelimit.js'
6
6
  import { PROVIDERS, displayName, droppedCredentialRefs, isProvider, oauthRef } from '../refs.js'
@@ -80,7 +80,7 @@ export function registerStatusRoutes(ctx, state) {
80
80
  if (payload && typeof payload.config === 'object') payload = payload.config
81
81
  try {
82
82
  if (Array.isArray(payload.slots)) payload.slots = stripLegacySlots(payload.slots)
83
- const parsed = Config(payload)
83
+ const parsed = plainConfig(Config(payload))
84
84
  const dropped = droppedCredentialRefs(live().slots, parsed.slots)
85
85
  await getSettingsApi().replace(parsed)
86
86
  currentRevision++
@@ -58,6 +58,9 @@ function headersFor(provider, blob, cfg) {
58
58
 
59
59
  export function createSubscriptionsService(deps) {
60
60
  return {
61
+ // The resolved config snapshot. Cached: identical between loader/volatile-update
62
+ // events, which is the guarantee issue #367 calls for.
63
+ live: () => deps.live(),
61
64
  async available(provider) {
62
65
  if (provider) {
63
66
  if (!isProvider(provider)) return false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.6.33",
3
+ "version": "0.6.35",
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",
@@ -54,11 +54,11 @@
54
54
  },
55
55
  "peerDependencies": {
56
56
  "@deepseek-ai/cordis": "^4.0.1",
57
- "@deepseek-ai/dsh-credentials": "^0.1.0-rc.6",
58
- "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
59
- "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
60
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
61
- "@deepseek-ai/schemastery": "^3.18.1"
57
+ "@deepseek-ai/dsh-credentials": "^0.1.7-rc.2 || ^0.2.0-rc.1",
58
+ "@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.2 || ^0.2.0-rc.1",
59
+ "@deepseek-ai/dsh-llm": "^0.1.7-rc.2 || ^0.2.0-rc.1",
60
+ "@deepseek-ai/dsh-settings": "^0.1.7-rc.2 || ^0.2.0-rc.1",
61
+ "@deepseek-ai/schemastery": "^3.18.4"
62
62
  },
63
63
  "publishConfig": {
64
64
  "access": "public"