@goodandready/dsh-clinebot 0.5.33 → 0.6.0

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