@goodandready/dsh-clinebot 0.6.29 → 0.6.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/config.js CHANGED
@@ -1,283 +1,283 @@
1
- import z from '@deepseek-ai/schemastery'
2
- import {
3
- DEFAULT_BASE_URL,
4
- DEFAULT_API_KEY_ENV,
5
- DEFAULT_TIMEOUT_MS,
6
- DEFAULT_SMOKE_TIMEOUT_MS,
7
- DEFAULT_STREAM_IDLE_TIMEOUT_MS,
8
- normalizeBaseUrl,
9
- } from './cline-client.js'
10
- import {
11
- DEFAULT_MODEL_ID,
12
- getAllModels,
13
- getActiveModelIds,
14
- migrateModelId,
15
- } from './models.js'
16
- import { setOwn } from './safe-dict.js'
17
-
18
- // DSH settings.describe only publishes fields marked volatile. Without them the
19
- // namespace is omitted and the configuration page stays unavailable.
20
- if (typeof z.prototype?.volatile !== 'function') {
21
- z.prototype.volatile = function volatile() {
22
- if (this.meta && this.meta.volatile) throw new TypeError('volatile schema is already wrapped')
23
- return typeof this.extra === 'function' ? this.extra('volatile', true) : this
24
- }
25
- }
26
-
27
- export const NS = 'dsh-clinebot'
28
- export const LLM_PI_AI_NS = 'llm-pi-ai'
29
-
30
- export const Config = z.object({
31
- enabled: z.boolean().default(true)
32
- .description('When true, ClineBot is registered as a model provider in DSH.').volatile(),
33
- baseUrl: z.string().default(DEFAULT_BASE_URL)
34
- .description('Base API URL (default: https://api.cline.bot/api/v1).').volatile(),
35
- apiKeyEnv: z.string().default(DEFAULT_API_KEY_ENV)
36
- .description('Credential / env name containing the ClinePass API key (never store key directly here).').volatile(),
37
- defaultModel: z.string().default(DEFAULT_MODEL_ID)
38
- .description('Default model ID for chat and smoke tests.').volatile(),
39
- disabledModels: z.array(z.string()).default([])
40
- .description('List of model IDs explicitly disabled by the user (new models are enabled automatically).').volatile(),
41
- filterModelsByPlan: z.boolean().default(true)
42
- .description('Automatically hide models not included in active subscription plan from chat picker.').volatile(),
43
- visibleModels: z.array(z.string()).default([])
44
- .description('User-selected visible models in chat model picker (empty means all supported models).').volatile(),
45
- showSidebarQuota: z.boolean().default(true)
46
- .description('Display compact quota meter in DSH sidebar footer.').volatile(),
47
- showComposerCost: z.boolean().default(true)
48
- .description('Display the estimated session cost widget in the chat composer.').volatile(),
49
- webSearch: z.boolean().default(true)
50
- .description('Enable native DSH web search provider via ClineBot.').volatile(),
51
- offloadSeenImages: z.boolean().default(true)
52
- .description('Offload previously seen images in conversation history to optimize prompt cache.').volatile(),
53
- maxPayloadMb: z.number().default(40)
54
- .description('Maximum multimodal payload budget in MB before evicting oldest images.').volatile(),
55
- priceOverrides: z.dict(z.object({
56
- input: z.number().default(0),
57
- output: z.number().default(0),
58
- cacheRead: z.number().default(0),
59
- cacheWrite: z.number().default(0),
60
- })).default({})
61
- .description('User-supplied USD per 1M token rates for local cost estimates.').volatile(),
62
- priceCatalogUrl: z.string().default('https://models.dev/api.json')
63
- .description('Public model price catalog URL used by the optional price sync.').volatile(),
64
- priceCatalogPath: z.string().default('~/.dsh/clinebot-prices.json')
65
- .description('Local cache for the public price catalog.').volatile(),
66
- priceAutoSync: z.boolean().default(true)
67
- .description('Automatically fetch the public price catalog when a newly discovered model has no price.').volatile(),
68
- eventLogPath: z.string().default('~/.dsh/clinebot-events.json')
69
- .description('Local ring buffer for account cooldown, recovery and routing events.').volatile(),
70
- enabledModels: z.array(z.string()).default([])
71
- .description('Deprecated: preserved for backwards compatibility with earlier versions.'),
72
- dynamicModels: z.array(z.object({
73
- id: z.string(),
74
- name: z.string(),
75
- description: z.string().default(''),
76
- contextLength: z.number().default(200000),
77
- maxTokens: z.number().default(8192),
78
- input: z.array(z.string()).default(['text']),
79
- category: z.string().default('general'),
80
- isCustom: z.boolean().default(false),
81
- free: z.boolean().default(false),
82
- reasoningEfforts: z.any().default(undefined),
83
- })).default([])
84
- .description('Models automatically discovered from the official ClinePass subscription plan.'),
85
- planSyncedAt: z.number().default(0)
86
- .description('Timestamp when models were last synchronized with active subscription plan.'),
87
- modelContextOverrides: z.array(z.object({
88
- modelId: z.string(),
89
- contextLength: z.number().default(200000),
90
- maxTokens: z.number().default(8192),
91
- })).default([])
92
- .description('Custom user-defined model context length and max token overrides.').volatile(),
93
- contextWindow: z.number().min(0).max(10000000).default(0)
94
- .description('Global custom context window in tokens (0 follows the catalog; per-model overrides win).').volatile(),
95
- maxOutputTokens: z.number().min(-1).max(2000000).default(-1)
96
- .description('Per-turn output cap: -1 derives from the model, 0 omits max_tokens, a positive value tightens the cap.').volatile(),
97
- modelReasoningDefaults: z.dict(z.string()).default({})
98
- .description('Configured default reasoning effort per model (low, medium, high, max).').volatile(),
99
- modelRoutes: z.dict(z.string()).default({})
100
- .description('Per-model target account routing map (e.g. { "deepseek-v4-pro": "KEY_2" }).').volatile(),
101
- customModels: z.array(z.object({
102
- id: z.string(),
103
- name: z.string(),
104
- contextLength: z.number().default(128000),
105
- maxTokens: z.number().default(8192),
106
- category: z.string().default('general'),
107
- isReasoning: z.boolean().default(false),
108
- })).default([])
109
- .description('User-defined custom gateway models.').volatile(),
110
- statsPath: z.string().default('~/.dsh/clinebot-stats.json')
111
- .description('Local on-disk persistent path for token usage analytics.').volatile(),
112
- statsRetentionDays: z.number().min(0).max(3650).default(95)
113
- .description('Retention for day/hour/session telemetry in days. 0 keeps everything.').volatile(),
114
- dailyBudgetUsd: z.number().min(0).default(0)
115
- .description('Optional daily estimated-spend guard in USD. 0 disables the guard.').volatile(),
116
- monthlyBudgetUsd: z.number().min(0).default(0)
117
- .description('Optional monthly estimated-spend guard in USD. 0 disables the guard.').volatile(),
118
- timeoutMs: z.number().default(DEFAULT_TIMEOUT_MS)
119
- .description('HTTP probe timeout in milliseconds.').volatile(),
120
- smokeTimeoutMs: z.number().default(DEFAULT_SMOKE_TIMEOUT_MS)
121
- .description('Timeout for smoke chat completions in milliseconds.').volatile(),
122
- streamIdleTimeoutMs: z.number().default(DEFAULT_STREAM_IDLE_TIMEOUT_MS)
123
- .description('Idle timeout between SSE stream chunks in milliseconds.').volatile(),
124
- modelsCachePath: z.string().default('~/.dsh/clinebot-models-cache.json')
125
- .description('Local on-disk cache path for models snapshot.').volatile(),
126
- accounts: z.array(z.object({
127
- label: z.string().default(''),
128
- apiKeyEnv: z.string(),
129
- })).default([])
130
- .description('Additional accounts for multi-account failover and rate limit rotation.').volatile(),
131
- activeAccount: z.string().default('')
132
- .description('Manually pinned active account envName or empty for auto/default.').volatile(),
133
- accountMode: z.union(['least_used', 'round_robin']).default('least_used')
134
- .description('Account selection strategy: least_used (quota-aware) or round_robin (sequential rotation).').volatile(),
135
- })
136
-
137
- export function isVolatileRef(value) {
138
- return !!value && typeof value === 'object' && !Array.isArray(value) && typeof value.get === 'function'
139
- }
140
-
141
- // DSH stores each volatile field as { get } or getter functions. structuredClone
142
- // cannot copy functions or getters, so callers need a plain snapshot before validation.
143
- export function plainConfig(cfg) {
144
- while (typeof cfg === 'function') {
145
- cfg = cfg()
146
- }
147
- if (isVolatileRef(cfg)) return plainConfig(cfg.get())
148
- if (!cfg || typeof cfg !== 'object') return cfg
149
- if (Array.isArray(cfg)) {
150
- return cfg.map((item) => plainConfig(item))
151
- }
152
- const out = {}
153
- for (const key of Object.keys(cfg)) {
154
- setOwn(out, key, plainConfig(cfg[key]))
155
- }
156
- return out
157
- }
158
-
159
- export function volatileConfig(cfg) {
160
- const plain = plainConfig(cfg)
161
- if (!plain || typeof plain !== 'object') return plain
162
- const out = {}
163
- for (const [key, field] of Object.entries(Config.dict || {})) {
164
- if (field?.meta?.volatile && Object.hasOwn(plain, key)) {
165
- setOwn(out, key, plain[key])
166
- }
167
- }
168
- return out
169
- }
170
-
171
- export function publicConfig(cfg) {
172
- cfg = plainConfig(cfg)
173
- const dynamic = Array.isArray(cfg?.dynamicModels) ? cfg.dynamicModels : []
174
- const custom = Array.isArray(cfg?.customModels) ? cfg.customModels : []
175
- const modelContextOverrides = Array.isArray(cfg?.modelContextOverrides) ? cfg.modelContextOverrides : []
176
- const contextWindow = Math.max(0, Number(cfg?.contextWindow) || 0)
177
- // A global context override is the wildcard entry; explicit per-model rows
178
- // and an explicitly stored `*` row keep precedence.
179
- const effectiveOverrides = contextWindow > 0 && !modelContextOverrides.some((entry) => entry?.modelId === '*')
180
- ? [...modelContextOverrides, { modelId: '*', contextLength: contextWindow, maxTokens: 0 }]
181
- : modelContextOverrides
182
- const isSynced = dynamic.length > 0 && (Number(cfg?.planSyncedAt) > 0)
183
- const allModels = getAllModels(dynamic, effectiveOverrides, custom)
184
- const allDefaultIds = allModels.map((m) => m.id)
185
-
186
- let disabledList = Array.isArray(cfg?.disabledModels) ? cfg.disabledModels.map(migrateModelId) : []
187
- if (cfg?.disabledModels === undefined && Array.isArray(cfg?.enabledModels) && cfg.enabledModels.length > 0) {
188
- const enabledSet = new Set(cfg.enabledModels.map(migrateModelId))
189
- disabledList = allDefaultIds.filter((id) => !enabledSet.has(id))
190
- }
191
-
192
- const filterModelsByPlan = cfg?.filterModelsByPlan !== false
193
- const visibleModels = Array.isArray(cfg?.visibleModels) ? cfg.visibleModels.map(migrateModelId) : []
194
-
195
- const planModelSet = new Set(dynamic.map((m) => m.id))
196
- for (const m of allModels) {
197
- if (m.inPlan === undefined) {
198
- m.inPlan = isSynced ? (planModelSet.has(m.id) || planModelSet.has(migrateModelId(m.id)) || Boolean(m.isCustom)) : true
199
- }
200
- }
201
-
202
- let activeIds = getActiveModelIds(allDefaultIds, disabledList)
203
- if (filterModelsByPlan && isSynced && planModelSet.size > 0) {
204
- const inPlanIds = activeIds.filter((id) => {
205
- const m = allModels.find((mod) => mod.id === id)
206
- return m ? m.inPlan : true
207
- })
208
- if (inPlanIds.length > 0) {
209
- activeIds = inPlanIds
210
- }
211
- }
212
- if (visibleModels.length > 0) {
213
- const visibleSet = new Set(visibleModels)
214
- const filteredByVisible = activeIds.filter((id) => visibleSet.has(id))
215
- if (filteredByVisible.length > 0) {
216
- activeIds = filteredByVisible
217
- }
218
- }
219
-
220
- if (!activeIds.length && allDefaultIds.length) {
221
- activeIds = [allDefaultIds[0]]
222
- }
223
-
224
- let defaultModel = migrateModelId(cfg?.defaultModel || DEFAULT_MODEL_ID)
225
- let defaultModelWarning = null
226
-
227
- if (allDefaultIds.length > 0 && !allDefaultIds.includes(defaultModel)) {
228
- const fallbackModel = activeIds[0] || allDefaultIds[0]
229
- defaultModelWarning = `Configured default model "${defaultModel}" is not available in your active plan. Using "${fallbackModel}" instead.`
230
- defaultModel = fallbackModel
231
- }
232
-
233
- const planSyncedAt = Number(cfg?.planSyncedAt) || (isSynced ? (dynamic.planSyncedAt || 0) : 0)
234
- const rawMaxOutput = Number(cfg?.maxOutputTokens)
235
- const maxOutputTokens = Number.isFinite(rawMaxOutput) ? (rawMaxOutput === -1 ? null : rawMaxOutput) : null
236
- const priceOverrides = cfg?.priceOverrides && typeof cfg.priceOverrides === 'object' && !Array.isArray(cfg.priceOverrides)
237
- ? cfg.priceOverrides
238
- : {}
239
-
240
- return {
241
- enabled: !!cfg?.enabled,
242
- baseUrl: normalizeBaseUrl(cfg?.baseUrl),
243
- apiKeyEnv: cfg?.apiKeyEnv || DEFAULT_API_KEY_ENV,
244
- defaultModel,
245
- defaultModelWarning,
246
- dynamicModels: dynamic,
247
- planSynced: isSynced,
248
- planSyncedAt,
249
- modelContextOverrides: effectiveOverrides,
250
- contextWindow,
251
- maxOutputTokens,
252
- priceOverrides,
253
- modelReasoningDefaults: cfg?.modelReasoningDefaults && typeof cfg.modelReasoningDefaults === 'object' ? cfg.modelReasoningDefaults : {},
254
- modelRoutes: cfg?.modelRoutes && typeof cfg.modelRoutes === 'object' ? cfg.modelRoutes : {},
255
- customModels: custom,
256
- statsPath: String(cfg?.statsPath || '~/.dsh/clinebot-stats.json'),
257
- priceCatalogUrl: String(cfg?.priceCatalogUrl || 'https://models.dev/api.json'),
258
- priceCatalogPath: String(cfg?.priceCatalogPath || '~/.dsh/clinebot-prices.json'),
259
- priceAutoSync: cfg?.priceAutoSync !== false,
260
- eventLogPath: String(cfg?.eventLogPath || '~/.dsh/clinebot-events.json'),
261
- statsRetentionDays: Number.isFinite(Number(cfg?.statsRetentionDays))
262
- ? Math.max(0, Math.min(3650, Number(cfg.statsRetentionDays)))
263
- : 95,
264
- dailyBudgetUsd: Math.max(0, Number(cfg?.dailyBudgetUsd) || 0),
265
- monthlyBudgetUsd: Math.max(0, Number(cfg?.monthlyBudgetUsd) || 0),
266
- disabledModels: disabledList,
267
- filterModelsByPlan,
268
- visibleModels,
269
- showSidebarQuota: cfg?.showSidebarQuota !== false,
270
- showComposerCost: cfg?.showComposerCost !== false,
271
- webSearch: cfg?.webSearch !== false,
272
- offloadSeenImages: cfg?.offloadSeenImages !== false,
273
- maxPayloadMb: Number(cfg?.maxPayloadMb) || 40,
274
- enabledModels: activeIds,
275
- timeoutMs: Number(cfg?.timeoutMs) || DEFAULT_TIMEOUT_MS,
276
- smokeTimeoutMs: Number(cfg?.smokeTimeoutMs) || DEFAULT_SMOKE_TIMEOUT_MS,
277
- streamIdleTimeoutMs: Number(cfg?.streamIdleTimeoutMs) || DEFAULT_STREAM_IDLE_TIMEOUT_MS,
278
- modelsCachePath: String(cfg?.modelsCachePath || '~/.dsh/clinebot-models-cache.json'),
279
- accounts: Array.isArray(cfg?.accounts) ? cfg.accounts : [],
280
- activeAccount: String(cfg?.activeAccount || ''),
281
- accountMode: cfg?.accountMode === 'round_robin' ? 'round_robin' : 'least_used',
282
- }
283
- }
1
+ import z from '@deepseek-ai/schemastery'
2
+ import {
3
+ DEFAULT_BASE_URL,
4
+ DEFAULT_API_KEY_ENV,
5
+ DEFAULT_TIMEOUT_MS,
6
+ DEFAULT_SMOKE_TIMEOUT_MS,
7
+ DEFAULT_STREAM_IDLE_TIMEOUT_MS,
8
+ normalizeBaseUrl,
9
+ } from './cline-client.js'
10
+ import {
11
+ DEFAULT_MODEL_ID,
12
+ getAllModels,
13
+ getActiveModelIds,
14
+ migrateModelId,
15
+ } from './models.js'
16
+ import { setOwn } from './safe-dict.js'
17
+
18
+ // DSH settings.describe only publishes fields marked volatile. Without them the
19
+ // namespace is omitted and the configuration page stays unavailable.
20
+ if (typeof z.prototype?.volatile !== 'function') {
21
+ z.prototype.volatile = function volatile() {
22
+ if (this.meta && this.meta.volatile) throw new TypeError('volatile schema is already wrapped')
23
+ return typeof this.extra === 'function' ? this.extra('volatile', true) : this
24
+ }
25
+ }
26
+
27
+ export const NS = 'dsh-clinebot'
28
+ export const LLM_PI_AI_NS = 'llm-pi-ai'
29
+
30
+ export const Config = z.object({
31
+ enabled: z.boolean().default(true)
32
+ .description('When true, ClineBot is registered as a model provider in DSH.').volatile(),
33
+ baseUrl: z.string().default(DEFAULT_BASE_URL)
34
+ .description('Base API URL (default: https://api.cline.bot/api/v1).').volatile(),
35
+ apiKeyEnv: z.string().default(DEFAULT_API_KEY_ENV)
36
+ .description('Credential / env name containing the ClinePass API key (never store key directly here).').volatile(),
37
+ defaultModel: z.string().default(DEFAULT_MODEL_ID)
38
+ .description('Default model ID for chat and smoke tests.').volatile(),
39
+ disabledModels: z.array(z.string()).default([])
40
+ .description('List of model IDs explicitly disabled by the user (new models are enabled automatically).').volatile(),
41
+ filterModelsByPlan: z.boolean().default(true)
42
+ .description('Automatically hide models not included in active subscription plan from chat picker.').volatile(),
43
+ visibleModels: z.array(z.string()).default([])
44
+ .description('User-selected visible models in chat model picker (empty means all supported models).').volatile(),
45
+ showSidebarQuota: z.boolean().default(false)
46
+ .description('Display compact quota meter in DSH sidebar footer.').volatile(),
47
+ showComposerCost: z.boolean().default(false)
48
+ .description('Display the estimated session cost widget in the chat composer.').volatile(),
49
+ webSearch: z.boolean().default(true)
50
+ .description('Enable native DSH web search provider via ClineBot.').volatile(),
51
+ offloadSeenImages: z.boolean().default(true)
52
+ .description('Offload previously seen images in conversation history to optimize prompt cache.').volatile(),
53
+ maxPayloadMb: z.number().default(40)
54
+ .description('Maximum multimodal payload budget in MB before evicting oldest images.').volatile(),
55
+ priceOverrides: z.dict(z.object({
56
+ input: z.number().default(0),
57
+ output: z.number().default(0),
58
+ cacheRead: z.number().default(0),
59
+ cacheWrite: z.number().default(0),
60
+ })).default({})
61
+ .description('User-supplied USD per 1M token rates for local cost estimates.').volatile(),
62
+ priceCatalogUrl: z.string().default('https://models.dev/api.json')
63
+ .description('Public model price catalog URL used by the optional price sync.').volatile(),
64
+ priceCatalogPath: z.string().default('~/.dsh/clinebot-prices.json')
65
+ .description('Local cache for the public price catalog.').volatile(),
66
+ priceAutoSync: z.boolean().default(true)
67
+ .description('Automatically fetch the public price catalog when a newly discovered model has no price.').volatile(),
68
+ eventLogPath: z.string().default('~/.dsh/clinebot-events.json')
69
+ .description('Local ring buffer for account cooldown, recovery and routing events.').volatile(),
70
+ enabledModels: z.array(z.string()).default([])
71
+ .description('Deprecated: preserved for backwards compatibility with earlier versions.'),
72
+ dynamicModels: z.array(z.object({
73
+ id: z.string(),
74
+ name: z.string(),
75
+ description: z.string().default(''),
76
+ contextLength: z.number().default(200000),
77
+ maxTokens: z.number().default(8192),
78
+ input: z.array(z.string()).default(['text']),
79
+ category: z.string().default('general'),
80
+ isCustom: z.boolean().default(false),
81
+ free: z.boolean().default(false),
82
+ reasoningEfforts: z.any().default(undefined),
83
+ })).default([])
84
+ .description('Models automatically discovered from the official ClinePass subscription plan.'),
85
+ planSyncedAt: z.number().default(0)
86
+ .description('Timestamp when models were last synchronized with active subscription plan.'),
87
+ modelContextOverrides: z.array(z.object({
88
+ modelId: z.string(),
89
+ contextLength: z.number().default(200000),
90
+ maxTokens: z.number().default(8192),
91
+ })).default([])
92
+ .description('Custom user-defined model context length and max token overrides.').volatile(),
93
+ contextWindow: z.number().min(0).max(10000000).default(0)
94
+ .description('Global custom context window in tokens (0 follows the catalog; per-model overrides win).').volatile(),
95
+ maxOutputTokens: z.number().min(-1).max(2000000).default(-1)
96
+ .description('Per-turn output cap: -1 derives from the model, 0 omits max_tokens, a positive value tightens the cap.').volatile(),
97
+ modelReasoningDefaults: z.dict(z.string()).default({})
98
+ .description('Configured default reasoning effort per model (low, medium, high, max).').volatile(),
99
+ modelRoutes: z.dict(z.string()).default({})
100
+ .description('Per-model target account routing map (e.g. { "deepseek-v4-pro": "KEY_2" }).').volatile(),
101
+ customModels: z.array(z.object({
102
+ id: z.string(),
103
+ name: z.string(),
104
+ contextLength: z.number().default(128000),
105
+ maxTokens: z.number().default(8192),
106
+ category: z.string().default('general'),
107
+ isReasoning: z.boolean().default(false),
108
+ })).default([])
109
+ .description('User-defined custom gateway models.').volatile(),
110
+ statsPath: z.string().default('~/.dsh/clinebot-stats.json')
111
+ .description('Local on-disk persistent path for token usage analytics.').volatile(),
112
+ statsRetentionDays: z.number().min(0).max(3650).default(95)
113
+ .description('Retention for day/hour/session telemetry in days. 0 keeps everything.').volatile(),
114
+ dailyBudgetUsd: z.number().min(0).default(0)
115
+ .description('Optional daily estimated-spend guard in USD. 0 disables the guard.').volatile(),
116
+ monthlyBudgetUsd: z.number().min(0).default(0)
117
+ .description('Optional monthly estimated-spend guard in USD. 0 disables the guard.').volatile(),
118
+ timeoutMs: z.number().default(DEFAULT_TIMEOUT_MS)
119
+ .description('HTTP probe timeout in milliseconds.').volatile(),
120
+ smokeTimeoutMs: z.number().default(DEFAULT_SMOKE_TIMEOUT_MS)
121
+ .description('Timeout for smoke chat completions in milliseconds.').volatile(),
122
+ streamIdleTimeoutMs: z.number().default(DEFAULT_STREAM_IDLE_TIMEOUT_MS)
123
+ .description('Idle timeout between SSE stream chunks in milliseconds.').volatile(),
124
+ modelsCachePath: z.string().default('~/.dsh/clinebot-models-cache.json')
125
+ .description('Local on-disk cache path for models snapshot.').volatile(),
126
+ accounts: z.array(z.object({
127
+ label: z.string().default(''),
128
+ apiKeyEnv: z.string(),
129
+ })).default([])
130
+ .description('Additional accounts for multi-account failover and rate limit rotation.').volatile(),
131
+ activeAccount: z.string().default('')
132
+ .description('Manually pinned active account envName or empty for auto/default.').volatile(),
133
+ accountMode: z.union(['least_used', 'round_robin']).default('least_used')
134
+ .description('Account selection strategy: least_used (quota-aware) or round_robin (sequential rotation).').volatile(),
135
+ })
136
+
137
+ export function isVolatileRef(value) {
138
+ return !!value && typeof value === 'object' && !Array.isArray(value) && typeof value.get === 'function'
139
+ }
140
+
141
+ // DSH stores each volatile field as { get } or getter functions. structuredClone
142
+ // cannot copy functions or getters, so callers need a plain snapshot before validation.
143
+ export function plainConfig(cfg) {
144
+ while (typeof cfg === 'function') {
145
+ cfg = cfg()
146
+ }
147
+ if (isVolatileRef(cfg)) return plainConfig(cfg.get())
148
+ if (!cfg || typeof cfg !== 'object') return cfg
149
+ if (Array.isArray(cfg)) {
150
+ return cfg.map((item) => plainConfig(item))
151
+ }
152
+ const out = {}
153
+ for (const key of Object.keys(cfg)) {
154
+ setOwn(out, key, plainConfig(cfg[key]))
155
+ }
156
+ return out
157
+ }
158
+
159
+ export function volatileConfig(cfg) {
160
+ const plain = plainConfig(cfg)
161
+ if (!plain || typeof plain !== 'object') return plain
162
+ const out = {}
163
+ for (const [key, field] of Object.entries(Config.dict || {})) {
164
+ if (field?.meta?.volatile && Object.hasOwn(plain, key)) {
165
+ setOwn(out, key, plain[key])
166
+ }
167
+ }
168
+ return out
169
+ }
170
+
171
+ export function publicConfig(cfg) {
172
+ cfg = plainConfig(cfg)
173
+ const dynamic = Array.isArray(cfg?.dynamicModels) ? cfg.dynamicModels : []
174
+ const custom = Array.isArray(cfg?.customModels) ? cfg.customModels : []
175
+ const modelContextOverrides = Array.isArray(cfg?.modelContextOverrides) ? cfg.modelContextOverrides : []
176
+ const contextWindow = Math.max(0, Number(cfg?.contextWindow) || 0)
177
+ // A global context override is the wildcard entry; explicit per-model rows
178
+ // and an explicitly stored `*` row keep precedence.
179
+ const effectiveOverrides = contextWindow > 0 && !modelContextOverrides.some((entry) => entry?.modelId === '*')
180
+ ? [...modelContextOverrides, { modelId: '*', contextLength: contextWindow, maxTokens: 0 }]
181
+ : modelContextOverrides
182
+ const isSynced = dynamic.length > 0 && (Number(cfg?.planSyncedAt) > 0)
183
+ const allModels = getAllModels(dynamic, effectiveOverrides, custom)
184
+ const allDefaultIds = allModels.map((m) => m.id)
185
+
186
+ let disabledList = Array.isArray(cfg?.disabledModels) ? cfg.disabledModels.map(migrateModelId) : []
187
+ if (cfg?.disabledModels === undefined && Array.isArray(cfg?.enabledModels) && cfg.enabledModels.length > 0) {
188
+ const enabledSet = new Set(cfg.enabledModels.map(migrateModelId))
189
+ disabledList = allDefaultIds.filter((id) => !enabledSet.has(id))
190
+ }
191
+
192
+ const filterModelsByPlan = cfg?.filterModelsByPlan !== false
193
+ const visibleModels = Array.isArray(cfg?.visibleModels) ? cfg.visibleModels.map(migrateModelId) : []
194
+
195
+ const planModelSet = new Set(dynamic.map((m) => m.id))
196
+ for (const m of allModels) {
197
+ if (m.inPlan === undefined) {
198
+ m.inPlan = isSynced ? (planModelSet.has(m.id) || planModelSet.has(migrateModelId(m.id)) || Boolean(m.isCustom)) : true
199
+ }
200
+ }
201
+
202
+ let activeIds = getActiveModelIds(allDefaultIds, disabledList)
203
+ if (filterModelsByPlan && isSynced && planModelSet.size > 0) {
204
+ const inPlanIds = activeIds.filter((id) => {
205
+ const m = allModels.find((mod) => mod.id === id)
206
+ return m ? m.inPlan : true
207
+ })
208
+ if (inPlanIds.length > 0) {
209
+ activeIds = inPlanIds
210
+ }
211
+ }
212
+ if (visibleModels.length > 0) {
213
+ const visibleSet = new Set(visibleModels)
214
+ const filteredByVisible = activeIds.filter((id) => visibleSet.has(id))
215
+ if (filteredByVisible.length > 0) {
216
+ activeIds = filteredByVisible
217
+ }
218
+ }
219
+
220
+ if (!activeIds.length && allDefaultIds.length) {
221
+ activeIds = [allDefaultIds[0]]
222
+ }
223
+
224
+ let defaultModel = migrateModelId(cfg?.defaultModel || DEFAULT_MODEL_ID)
225
+ let defaultModelWarning = null
226
+
227
+ if (allDefaultIds.length > 0 && !allDefaultIds.includes(defaultModel)) {
228
+ const fallbackModel = activeIds[0] || allDefaultIds[0]
229
+ defaultModelWarning = `Configured default model "${defaultModel}" is not available in your active plan. Using "${fallbackModel}" instead.`
230
+ defaultModel = fallbackModel
231
+ }
232
+
233
+ const planSyncedAt = Number(cfg?.planSyncedAt) || (isSynced ? (dynamic.planSyncedAt || 0) : 0)
234
+ const rawMaxOutput = Number(cfg?.maxOutputTokens)
235
+ const maxOutputTokens = Number.isFinite(rawMaxOutput) ? (rawMaxOutput === -1 ? null : rawMaxOutput) : null
236
+ const priceOverrides = cfg?.priceOverrides && typeof cfg.priceOverrides === 'object' && !Array.isArray(cfg.priceOverrides)
237
+ ? cfg.priceOverrides
238
+ : {}
239
+
240
+ return {
241
+ enabled: !!cfg?.enabled,
242
+ baseUrl: normalizeBaseUrl(cfg?.baseUrl),
243
+ apiKeyEnv: cfg?.apiKeyEnv || DEFAULT_API_KEY_ENV,
244
+ defaultModel,
245
+ defaultModelWarning,
246
+ dynamicModels: dynamic,
247
+ planSynced: isSynced,
248
+ planSyncedAt,
249
+ modelContextOverrides: effectiveOverrides,
250
+ contextWindow,
251
+ maxOutputTokens,
252
+ priceOverrides,
253
+ modelReasoningDefaults: cfg?.modelReasoningDefaults && typeof cfg.modelReasoningDefaults === 'object' ? cfg.modelReasoningDefaults : {},
254
+ modelRoutes: cfg?.modelRoutes && typeof cfg.modelRoutes === 'object' ? cfg.modelRoutes : {},
255
+ customModels: custom,
256
+ statsPath: String(cfg?.statsPath || '~/.dsh/clinebot-stats.json'),
257
+ priceCatalogUrl: String(cfg?.priceCatalogUrl || 'https://models.dev/api.json'),
258
+ priceCatalogPath: String(cfg?.priceCatalogPath || '~/.dsh/clinebot-prices.json'),
259
+ priceAutoSync: cfg?.priceAutoSync !== false,
260
+ eventLogPath: String(cfg?.eventLogPath || '~/.dsh/clinebot-events.json'),
261
+ statsRetentionDays: Number.isFinite(Number(cfg?.statsRetentionDays))
262
+ ? Math.max(0, Math.min(3650, Number(cfg.statsRetentionDays)))
263
+ : 95,
264
+ dailyBudgetUsd: Math.max(0, Number(cfg?.dailyBudgetUsd) || 0),
265
+ monthlyBudgetUsd: Math.max(0, Number(cfg?.monthlyBudgetUsd) || 0),
266
+ disabledModels: disabledList,
267
+ filterModelsByPlan,
268
+ visibleModels,
269
+ showSidebarQuota: Boolean(cfg?.showSidebarQuota),
270
+ showComposerCost: Boolean(cfg?.showComposerCost),
271
+ webSearch: cfg?.webSearch !== false,
272
+ offloadSeenImages: cfg?.offloadSeenImages !== false,
273
+ maxPayloadMb: Number(cfg?.maxPayloadMb) || 40,
274
+ enabledModels: activeIds,
275
+ timeoutMs: Number(cfg?.timeoutMs) || DEFAULT_TIMEOUT_MS,
276
+ smokeTimeoutMs: Number(cfg?.smokeTimeoutMs) || DEFAULT_SMOKE_TIMEOUT_MS,
277
+ streamIdleTimeoutMs: Number(cfg?.streamIdleTimeoutMs) || DEFAULT_STREAM_IDLE_TIMEOUT_MS,
278
+ modelsCachePath: String(cfg?.modelsCachePath || '~/.dsh/clinebot-models-cache.json'),
279
+ accounts: Array.isArray(cfg?.accounts) ? cfg.accounts : [],
280
+ activeAccount: String(cfg?.activeAccount || ''),
281
+ accountMode: cfg?.accountMode === 'round_robin' ? 'round_robin' : 'least_used',
282
+ }
283
+ }