@goodandready/dsh-clinebot 0.4.7 → 0.5.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.
@@ -26,7 +26,7 @@ export const DEFAULT_BASE_URL = 'https://api.cline.bot/api/v1'
26
26
  export const DEFAULT_API_KEY_ENV = 'CLINEBOT_API_KEY'
27
27
  export const DEFAULT_TIMEOUT_MS = 15000
28
28
  export const DEFAULT_SMOKE_TIMEOUT_MS = 25000
29
- export const USAGE_CACHE_TTL_MS = 60000
29
+ const USAGE_CACHE_TTL_MS = 60000
30
30
 
31
31
  export { PROVIDER_ID, PROVIDER_DISPLAY_NAME, DEFAULT_MODEL_ID }
32
32
 
@@ -508,4 +508,3 @@ export function buildPiAiProvider({
508
508
 
509
509
 
510
510
 
511
- export { resolveAccountPool, isAccountQuotaExhausted, rotateToNextAccount, getLastRotation, setLastRotation, isQuotaExceededError } from './account-pool.js'
package/lib/config.js CHANGED
@@ -12,7 +12,6 @@ import {
12
12
  getActiveModelIds,
13
13
  } from './models.js'
14
14
 
15
-
16
15
  // DSH settings.describe only publishes fields marked volatile. Without them the
17
16
  // namespace is omitted and the configuration page stays unavailable.
18
17
  if (typeof z.prototype?.volatile !== 'function') {
@@ -58,6 +57,21 @@ export const Config = z.object({
58
57
  maxTokens: z.number().default(8192),
59
58
  })).default([])
60
59
  .description('Custom user-defined model context length and max token overrides.').volatile(),
60
+ modelReasoningDefaults: z.dict(z.string()).default({})
61
+ .description('Configured default reasoning effort per model (low, medium, high, max).').volatile(),
62
+ customModels: z.array(z.object({
63
+ id: z.string(),
64
+ name: z.string(),
65
+ contextLength: z.number().default(128000),
66
+ maxTokens: z.number().default(8192),
67
+ category: z.string().default('general'),
68
+ isReasoning: z.boolean().default(false),
69
+ })).default([])
70
+ .description('User-defined custom gateway models.').volatile(),
71
+ proxyMode: z.boolean().default(true)
72
+ .description('When true, registers local transparent loopback proxy for failover and token tracking.').volatile(),
73
+ statsPath: z.string().default('~/.dsh/clinebot-stats.json')
74
+ .description('Local on-disk persistent path for token usage analytics.').volatile(),
61
75
  timeoutMs: z.number().default(DEFAULT_TIMEOUT_MS)
62
76
  .description('HTTP probe timeout in milliseconds.').volatile(),
63
77
  smokeTimeoutMs: z.number().default(DEFAULT_SMOKE_TIMEOUT_MS)
@@ -73,7 +87,6 @@ export const Config = z.object({
73
87
  .description('Manually pinned active account envName or empty for auto/default.').volatile(),
74
88
  })
75
89
 
76
-
77
90
  function isVolatileRef(value) {
78
91
  return !!value && typeof value === 'object' && !Array.isArray(value) && typeof value.get === 'function'
79
92
  }
@@ -111,9 +124,10 @@ export function volatileConfig(cfg) {
111
124
  export function publicConfig(cfg) {
112
125
  cfg = plainConfig(cfg)
113
126
  const dynamic = Array.isArray(cfg?.dynamicModels) ? cfg.dynamicModels : []
127
+ const custom = Array.isArray(cfg?.customModels) ? cfg.customModels : []
114
128
  const modelContextOverrides = Array.isArray(cfg?.modelContextOverrides) ? cfg.modelContextOverrides : []
115
129
  const isSynced = dynamic.length > 0
116
- const allModels = getAllModels(dynamic, modelContextOverrides)
130
+ const allModels = getAllModels(dynamic, modelContextOverrides, custom)
117
131
  const allDefaultIds = allModels.map((m) => m.id)
118
132
 
119
133
  let disabledList = Array.isArray(cfg?.disabledModels) ? cfg.disabledModels : []
@@ -148,6 +162,10 @@ export function publicConfig(cfg) {
148
162
  planSynced: isSynced,
149
163
  planSyncedAt,
150
164
  modelContextOverrides,
165
+ modelReasoningDefaults: cfg?.modelReasoningDefaults && typeof cfg.modelReasoningDefaults === 'object' ? cfg.modelReasoningDefaults : {},
166
+ customModels: custom,
167
+ proxyMode: cfg?.proxyMode !== false,
168
+ statsPath: String(cfg?.statsPath || '~/.dsh/clinebot-stats.json'),
151
169
  disabledModels: disabledList,
152
170
  enabledModels: activeIds,
153
171
  timeoutMs: Number(cfg?.timeoutMs) || DEFAULT_TIMEOUT_MS,
package/lib/http.js CHANGED
@@ -46,7 +46,7 @@ function header(request, name) {
46
46
  return Array.isArray(value) ? value[0] : value
47
47
  }
48
48
 
49
- function isLoopbackAddress(value) {
49
+ export function isLoopbackAddress(value) {
50
50
  const address = String(value || '').toLowerCase().replace(/^\[|\]$/g, '')
51
51
  return address === 'localhost' || address === 'localhost.' || address === '::1'
52
52
  || address.startsWith('127.')
package/lib/index.js CHANGED
@@ -4,9 +4,11 @@ import {
4
4
  sessionStats,
5
5
  recordSessionRequest,
6
6
  resetSessionStats,
7
+ } from './cline-client.js'
8
+ import {
7
9
  rotateToNextAccount,
8
10
  isQuotaExceededError,
9
- } from './cline-client.js'
11
+ } from './account-pool.js'
10
12
  import { PROVIDER_ID } from './models.js'
11
13
  import {
12
14
  upsertPiAiProvider,
@@ -18,7 +20,9 @@ import { registerSettingsRoutes } from './routes/settings.js'
18
20
  import { registerAccountsRoutes } from './routes/accounts.js'
19
21
  import { registerModelsRoutes } from './routes/models.js'
20
22
  import { registerAuthRoutes } from './routes/auth.js'
23
+ import { registerProxyRoutes } from './routes/proxy.js'
21
24
  import { registerSlashCommand } from './slash-command.js'
25
+ import { recordUsage } from './stats-storage.js'
22
26
 
23
27
  export const name = '@goodandready/dsh-clinebot'
24
28
  export const inject = ['webServer', 'credentials']
@@ -135,6 +139,7 @@ export function apply(ctx, config) {
135
139
  registerAccountsRoutes(ctx, routeEnv)
136
140
  registerModelsRoutes(ctx, routeEnv)
137
141
  registerAuthRoutes(ctx, routeEnv)
142
+ registerProxyRoutes(ctx, routeEnv)
138
143
  }
139
144
 
140
145
  registerSlashCommand(ctx, {
@@ -198,6 +203,15 @@ export function apply(ctx, config) {
198
203
  completionTokens,
199
204
  model: options?.model,
200
205
  })
206
+ recordUsage({
207
+ model: options?.model,
208
+ promptTokens,
209
+ completionTokens,
210
+ totalTokens: promptTokens + completionTokens,
211
+ isError: true,
212
+ is429,
213
+ statsPath: live().statsPath,
214
+ })
201
215
  if (is429 || isExceeded) {
202
216
  handleStream429()
203
217
  }
@@ -218,6 +232,13 @@ export function apply(ctx, config) {
218
232
  completionTokens,
219
233
  model: options?.model,
220
234
  })
235
+ recordUsage({
236
+ model: options?.model,
237
+ promptTokens,
238
+ completionTokens,
239
+ totalTokens: promptTokens + completionTokens,
240
+ statsPath: live().statsPath,
241
+ })
221
242
  }
222
243
  }
223
244
  yield chunk
@@ -230,6 +251,13 @@ export function apply(ctx, config) {
230
251
  completionTokens,
231
252
  model: options?.model,
232
253
  })
254
+ recordUsage({
255
+ model: options?.model,
256
+ promptTokens,
257
+ completionTokens,
258
+ totalTokens: promptTokens + completionTokens,
259
+ statsPath: live().statsPath,
260
+ })
233
261
  }
234
262
  } catch (err) {
235
263
  if (isCline) {
@@ -244,6 +272,15 @@ export function apply(ctx, config) {
244
272
  completionTokens,
245
273
  model: options?.model,
246
274
  })
275
+ recordUsage({
276
+ model: options?.model,
277
+ promptTokens,
278
+ completionTokens,
279
+ totalTokens: promptTokens + completionTokens,
280
+ isError: true,
281
+ is429: status === 429,
282
+ statsPath: live().statsPath,
283
+ })
247
284
  if (status === 429 || isQuotaExceededError(msg)) {
248
285
  handleStream429()
249
286
  }
package/lib/models.js CHANGED
@@ -20,7 +20,7 @@ export const OBSOLETE_MODEL_ID_MAP = Object.freeze({
20
20
  'cline-pass/muse-spark-13-contributor': 'cline-pass/muse-spark-1.3-contributor',
21
21
  })
22
22
 
23
- export function migrateModelId(id) {
23
+ function migrateModelId(id) {
24
24
  if (typeof id !== 'string') return id
25
25
  return OBSOLETE_MODEL_ID_MAP[id] || id
26
26
  }
@@ -57,7 +57,7 @@ export function getOriginalModelContext(nameOrId) {
57
57
  return 200000
58
58
  }
59
59
 
60
- export function getOriginalModelProvider(nameOrId) {
60
+ function getOriginalModelProvider(nameOrId) {
61
61
  const norm = String(nameOrId || '').toLowerCase()
62
62
  if (norm.includes('kimi')) return 'Moonshot AI'
63
63
  if (norm.includes('qwen')) return 'Alibaba Cloud'
@@ -336,7 +336,7 @@ export function parsePlanIncludedModels(includedInput) {
336
336
  *
337
337
  * Applies user-defined modelContextOverrides (custom contextLength and maxTokens).
338
338
  */
339
- export function getAllModels(dynamicModels = [], modelContextOverrides = []) {
339
+ export function getAllModels(dynamicModels = [], modelContextOverrides = [], customModels = []) {
340
340
  const normalizedDynamic = (Array.isArray(dynamicModels) ? dynamicModels : []).map(migrateModelEntry)
341
341
  const overridesMap = new Map()
342
342
  if (Array.isArray(modelContextOverrides)) {
@@ -374,9 +374,10 @@ export function getAllModels(dynamicModels = [], modelContextOverrides = []) {
374
374
  }
375
375
  }
376
376
 
377
+ let baseModels = []
377
378
  if (normalizedDynamic.length > 0) {
378
379
  const normalize = (s) => String(s || '').toLowerCase().replace(/[\s._-]+/g, '')
379
- return normalizedDynamic.map((dm) => {
380
+ baseModels = normalizedDynamic.map((dm) => {
380
381
  const targetNorm = normalize(dm.id.replace(/^cline-pass\//, ''))
381
382
  const curated = CLINE_MODELS.find(
382
383
  (cm) => cm.id === dm.id || normalize(cm.id.replace(/^cline-pass\//, '')) === targetNorm || normalize(cm.name) === targetNorm
@@ -407,23 +408,47 @@ export function getAllModels(dynamicModels = [], modelContextOverrides = []) {
407
408
  unverified: false,
408
409
  })
409
410
  })
411
+ } else {
412
+ baseModels = CLINE_MODELS.map((m) => applyOverride({ ...m, unverified: true }))
413
+ }
414
+
415
+ if (Array.isArray(customModels) && customModels.length > 0) {
416
+ const existingIds = new Set(baseModels.map((m) => m.id))
417
+ for (const cm of customModels) {
418
+ if (cm && cm.id && !existingIds.has(cm.id)) {
419
+ const fullCustom = {
420
+ id: cm.id,
421
+ name: cm.name || cm.id,
422
+ description: cm.description || `Custom model ${cm.name || cm.id}`,
423
+ contextLength: cm.contextLength || 128000,
424
+ maxTokens: cm.maxTokens || 8192,
425
+ input: ['text'],
426
+ category: cm.category || 'general',
427
+ isCustom: true,
428
+ reasoningEfforts: cm.isReasoning ? ['low', 'medium', 'high', 'max'] : undefined,
429
+ unverified: false,
430
+ }
431
+ baseModels.push(applyOverride(fullCustom))
432
+ existingIds.add(cm.id)
433
+ }
434
+ }
410
435
  }
411
436
 
412
- return CLINE_MODELS.map((m) => applyOverride({ ...m, unverified: true }))
437
+ return baseModels
413
438
  }
414
439
 
415
- export function findModel(id, dynamicModels = [], modelContextOverrides = []) {
440
+ function findModel(id, dynamicModels = [], modelContextOverrides = [], customModels = []) {
416
441
  const all = getAllModels(dynamicModels, modelContextOverrides)
417
442
  const found = all.find((m) => m.id === id)
418
443
  if (found) return found
419
444
  return CLINE_MODELS.find((m) => m.id === id) || null
420
445
  }
421
446
 
422
- export function isSupportedModel(id, dynamicModels = [], modelContextOverrides = []) {
447
+ export function isSupportedModel(id, dynamicModels = [], modelContextOverrides = [], customModels = []) {
423
448
  return findModel(id, dynamicModels, modelContextOverrides) !== null
424
449
  }
425
450
 
426
- export function getDefaultModelIds(dynamicModels = [], modelContextOverrides = []) {
451
+ function getDefaultModelIds(dynamicModels = [], modelContextOverrides = [], customModels = []) {
427
452
  return getAllModels(dynamicModels, modelContextOverrides).map((m) => m.id)
428
453
  }
429
454
 
@@ -1,30 +1,29 @@
1
1
  import os from 'node:os'
2
2
  import path from 'node:path'
3
- import { publicConfig, plainConfig, Config, LLM_PI_AI_NS } from './config.js'
4
- import { publicUsage } from './http.js'
5
3
  import {
6
4
  PROVIDER_ID,
7
5
  PROVIDER_DISPLAY_NAME,
8
6
  getAllModels,
9
- loadModelsDiskCache,
10
7
  saveModelsDiskCache,
8
+ loadModelsDiskCache,
9
+ OBSOLETE_MODEL_ID_MAP,
10
+ migrateModelEntry,
11
11
  } from './models.js'
12
12
  import {
13
- resolveKeyValue,
14
- resolveAccountPool,
15
13
  probeHealth,
16
14
  fetchUsageLimits,
17
- buildPiAiProvider,
18
15
  sessionStats,
19
- getLastRotation,
20
16
  usageCache,
17
+ buildPiAiProvider,
21
18
  DEFAULT_API_KEY_ENV,
22
19
  } from './cline-client.js'
20
+ import { publicConfig, plainConfig, Config, LLM_PI_AI_NS } from './config.js'
21
+ import { publicUsage } from './http.js'
22
+ import { resolveKeyValue } from './credential-refs.js'
23
+ import { resolveAccountPool, getLastRotation } from './account-pool.js'
23
24
 
24
25
  export function resolvePathWithHome(p) {
25
- if (!p || typeof p !== 'string') return ''
26
- if (p === '~') return os.homedir()
27
- if (p.startsWith('~/') || p.startsWith('~\\')) {
26
+ if (typeof p === 'string' && p.startsWith('~/')) {
28
27
  return path.join(os.homedir(), p.slice(2))
29
28
  }
30
29
  return p
@@ -88,7 +87,7 @@ export async function buildStatus(ctx, cfg) {
88
87
  ])
89
88
 
90
89
  const activeAcc = await resolveActiveAccountKey(ctx, cfg, pool)
91
- const allModels = getAllModels(pub.dynamicModels, pub.modelContextOverrides)
90
+ const allModels = getAllModels(pub.dynamicModels, pub.modelContextOverrides, pub.customModels)
92
91
 
93
92
  let usage = null
94
93
  const keyToUse = activeAcc.value || key.value
@@ -97,19 +96,40 @@ export async function buildStatus(ctx, cfg) {
97
96
  }
98
97
 
99
98
  let quotaWarning = null
100
- if (usage?.windows?.fiveHour) {
101
- const pct = usage.windows.fiveHour.percentUsed
102
- if (pct >= 95) {
99
+ if (usage?.windows) {
100
+ const fiveHour = usage.windows.fiveHour
101
+ const weekly = usage.windows.weekly
102
+ const monthly = usage.windows.monthly
103
+
104
+ if (fiveHour && typeof fiveHour.percentUsed === 'number') {
105
+ if (fiveHour.percentUsed >= 95) {
106
+ quotaWarning = {
107
+ level: 'exhausted',
108
+ message: `5-hour rolling limit is almost exhausted (${fiveHour.percentUsed}%). New requests may be rejected until quota reset.`,
109
+ resetsAt: fiveHour.resetsAt,
110
+ }
111
+ } else if (fiveHour.percentUsed >= 90) {
112
+ quotaWarning = {
113
+ level: 'warning',
114
+ message: `Low quota warning: ${fiveHour.percentUsed}% of 5-hour limit consumed (<10% remaining).`,
115
+ resetsAt: fiveHour.resetsAt,
116
+ }
117
+ }
118
+ }
119
+
120
+ if (!quotaWarning && weekly && typeof weekly.percentUsed === 'number' && weekly.percentUsed >= 90) {
103
121
  quotaWarning = {
104
- level: 'exhausted',
105
- message: `5-hour rolling limit is almost exhausted (${pct}%). New requests may be rejected until quota reset.`,
106
- resetsAt: usage.windows.fiveHour.resetsAt,
122
+ level: weekly.percentUsed >= 99 ? 'exhausted' : 'warning',
123
+ message: `Low weekly quota warning: ${weekly.percentUsed}% consumed (${weekly.remainingPercent ?? (100 - weekly.percentUsed)}% remaining).`,
124
+ resetsAt: weekly.resetsAt,
107
125
  }
108
- } else if (pct >= 80) {
126
+ }
127
+
128
+ if (!quotaWarning && monthly && typeof monthly.percentUsed === 'number' && monthly.percentUsed >= 90) {
109
129
  quotaWarning = {
110
- level: 'warning',
111
- message: `Notice: ${pct}% of the 5-hour rolling limit has been consumed.`,
112
- resetsAt: usage.windows.fiveHour.resetsAt,
130
+ level: monthly.percentUsed >= 99 ? 'exhausted' : 'warning',
131
+ message: `Low monthly quota warning: ${monthly.percentUsed}% consumed (${monthly.remainingPercent ?? (100 - monthly.percentUsed)}% remaining).`,
132
+ resetsAt: monthly.resetsAt,
113
133
  }
114
134
  }
115
135
  }
@@ -127,7 +147,7 @@ export async function buildStatus(ctx, cfg) {
127
147
  accounts: pool.map((acc) => {
128
148
  const cacheKey = `cline:usage:${(acc.value || '').slice(-8)}`
129
149
  const cached = usageCache.get(cacheKey)?.data
130
- const pct = cached?.windows?.fiveHour?.percentUsed
150
+ const pct = cached?.windows?.weekly?.percentUsed ?? cached?.windows?.monthly?.percentUsed ?? cached?.windows?.fiveHour?.percentUsed ?? null
131
151
  return {
132
152
  id: acc.id,
133
153
  label: acc.label,
@@ -156,17 +176,22 @@ export async function upsertPiAiProvider(ctx, cfg, activeModelIds) {
156
176
  }
157
177
 
158
178
  const pub = publicConfig(cfg)
159
- const allModels = getAllModels(pub.dynamicModels, pub.modelContextOverrides)
179
+ const allModels = getAllModels(pub.dynamicModels, pub.modelContextOverrides, pub.customModels)
160
180
  const allowedSet = new Set(activeModelIds || pub.enabledModels)
161
181
  const modelsToRegister = allModels.filter((m) => allowedSet.has(m.id))
162
182
 
163
183
  const activeAcc = await resolveActiveAccountKey(ctx, cfg)
164
184
 
185
+ const port = ctx?.webServer?.port || process.env.PORT || 3080
186
+ const effectiveBaseUrl = pub.proxyMode !== false
187
+ ? `http://127.0.0.1:${port}/dsh-clinebot/v1`
188
+ : pub.baseUrl
189
+
165
190
  const providerObj = buildPiAiProvider({
166
- baseUrl: pub.baseUrl,
191
+ baseUrl: effectiveBaseUrl,
167
192
  apiKeyEnv: activeAcc?.apiKeyEnv || pub.apiKeyEnv,
168
193
  models: modelsToRegister.length ? modelsToRegister : allModels,
169
- customModels: pub.dynamicModels,
194
+ customModels: [...(pub.dynamicModels || []), ...(pub.customModels || [])],
170
195
  displayName: PROVIDER_DISPLAY_NAME,
171
196
  })
172
197
 
@@ -5,10 +5,10 @@ import { resolveActiveAccountKey } from '../provider-sync.js'
5
5
  import {
6
6
  smokeChat,
7
7
  recordSmokeTest,
8
- rotateToNextAccount,
9
8
  DEFAULT_MODEL_ID,
10
9
  normalizeBaseUrl,
11
10
  } from '../cline-client.js'
11
+ import { rotateToNextAccount } from '../account-pool.js'
12
12
 
13
13
  export function registerAuthRoutes(ctx, { live, getSettingsApi, syncProviderState }) {
14
14
  // POST /dsh-clinebot/key/verify — on-the-fly verification of Cline API key