@goodandready/dsh-clinebot 0.3.24 → 0.4.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.
@@ -1,6 +1,6 @@
1
1
  import { writeJson, readBody } from '../http.js'
2
2
  import { isTrustedSettingsRequest, assertTrustedSettingsRequest } from '../access.js'
3
- import { publicConfig, Config } from '../config.js'
3
+ import { publicConfig, plainConfig, Config } from '../config.js'
4
4
  import { resolveActiveAccountKey, resolvePathWithHome } from '../provider-sync.js'
5
5
  import { getAllModels, saveModelsDiskCache } from '../models.js'
6
6
  import { fetchUsageLimits } from '../cline-client.js'
@@ -35,18 +35,21 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
35
35
  const dynamicModels = Array.isArray(usageData.dynamicModels) ? usageData.dynamicModels : []
36
36
  const allModels = getAllModels(dynamicModels)
37
37
  const settingsApi = getSettingsApi()
38
+ if (!settingsApi || typeof settingsApi.replace !== 'function') {
39
+ return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
40
+ }
38
41
 
39
- if (settingsApi?.replace) {
40
- const next = Config({
41
- ...live(),
42
- dynamicModels,
43
- })
44
- await settingsApi.replace(next)
45
- await syncProviderState(next)
46
- const cacheFile = resolvePathWithHome(pub.modelsCachePath)
47
- if (cacheFile) {
48
- await saveModelsDiskCache(cacheFile, dynamicModels)
49
- }
42
+ const now = Date.now()
43
+ const next = Config({
44
+ ...plainConfig(live()),
45
+ dynamicModels,
46
+ planSyncedAt: now,
47
+ })
48
+ await settingsApi.replace(next)
49
+ await syncProviderState(next)
50
+ const cacheFile = resolvePathWithHome(pub.modelsCachePath)
51
+ if (cacheFile) {
52
+ await saveModelsDiskCache(cacheFile, dynamicModels, now)
50
53
  }
51
54
 
52
55
  return writeJson(res, 200, {
@@ -75,23 +78,23 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
75
78
  try { body = JSON.parse(bodyBuf.toString('utf8')) } catch { body = {} }
76
79
 
77
80
  const settingsApi = getSettingsApi()
78
- if (settingsApi?.replace) {
79
- const patch = {}
80
- if (Array.isArray(body.disabledModels)) {
81
- patch.disabledModels = body.disabledModels
82
- } else if (Array.isArray(body.enabledModels)) {
83
- const allModels = getAllModels(live().dynamicModels)
84
- const enabledSet = new Set(body.enabledModels)
85
- patch.disabledModels = allModels.map((m) => m.id).filter((id) => !enabledSet.has(id))
86
- }
87
- if (body.defaultModel) patch.defaultModel = body.defaultModel
88
- const next = Config({ ...live(), ...patch })
89
- await settingsApi.replace(next)
90
- await syncProviderState(next)
91
- writeJson(res, 200, { ok: true, disabledModels: next.disabledModels, enabledModels: publicConfig(next).enabledModels })
92
- } else {
93
- writeJson(res, 200, { ok: true })
81
+ if (!settingsApi || typeof settingsApi.replace !== 'function') {
82
+ return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
83
+ }
84
+
85
+ const patch = {}
86
+ if (Array.isArray(body.disabledModels)) {
87
+ patch.disabledModels = body.disabledModels
88
+ } else if (Array.isArray(body.enabledModels)) {
89
+ const allModels = getAllModels(publicConfig(live()).dynamicModels)
90
+ const enabledSet = new Set(body.enabledModels)
91
+ patch.disabledModels = allModels.map((m) => m.id).filter((id) => !enabledSet.has(id))
94
92
  }
93
+ if (body.defaultModel) patch.defaultModel = body.defaultModel
94
+ const next = Config({ ...plainConfig(live()), ...patch })
95
+ await settingsApi.replace(next)
96
+ await syncProviderState(next)
97
+ writeJson(res, 200, { ok: true, disabledModels: next.disabledModels, enabledModels: publicConfig(next).enabledModels })
95
98
  } catch (err) {
96
99
  writeJson(res, 500, { ok: false, error: String(err?.message || err) })
97
100
  }
@@ -1,6 +1,6 @@
1
1
  import { writeJson, readBody } from '../http.js'
2
2
  import { isTrustedSettingsRequest, assertTrustedSettingsRequest } from '../access.js'
3
- import { publicConfig, Config } from '../config.js'
3
+ import { publicConfig, plainConfig, Config } from '../config.js'
4
4
  import { buildStatus } from '../provider-sync.js'
5
5
  import { saveCredentialKey, smokeChat, DEFAULT_API_KEY_ENV } from '../cline-client.js'
6
6
 
@@ -33,22 +33,42 @@ export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProvider
33
33
  if (req.method !== 'PUT') {
34
34
  return writeJson(res, 405, { ok: false, error: 'GET or PUT' })
35
35
  }
36
- if (!isTrustedSettingsRequest(req)) {
37
- return writeJson(res, 403, { ok: false, error: 'same-origin only' })
38
- }
36
+ if (!assertTrustedSettingsRequest(req, res)) return
39
37
  const settingsApi = getSettingsApi()
40
38
  if (!settingsApi) {
41
39
  return writeJson(res, 503, { ok: false, error: 'settings not ready' })
42
40
  }
41
+ let bodyBuf
42
+ try {
43
+ bodyBuf = await readBody(req)
44
+ } catch (err) {
45
+ return writeJson(res, 400, { ok: false, error: String(err?.message || err) })
46
+ }
43
47
  let payload
44
48
  try {
45
- payload = JSON.parse((await readBody(req)).toString('utf8') || '{}')
49
+ payload = JSON.parse(bodyBuf.toString('utf8') || '{}')
46
50
  } catch {
47
51
  return writeJson(res, 400, { ok: false, error: 'invalid json' })
48
52
  }
49
- if (payload && typeof payload.config === 'object') payload = payload.config
53
+ const rawPayload = payload && typeof payload.config === 'object' && payload.config !== null ? payload.config : payload
54
+ if (!rawPayload || typeof rawPayload !== 'object' || Array.isArray(rawPayload)) {
55
+ return writeJson(res, 400, { ok: false, error: 'body must be a JSON object' })
56
+ }
57
+ if ('enabledModels' in rawPayload) {
58
+ return writeJson(res, 400, { ok: false, error: 'enabledModels is deprecated and not allowed; configure disabledModels instead' })
59
+ }
60
+ const allowedKeys = new Set(Object.keys(Config.dict || {}))
61
+ allowedKeys.delete('enabledModels')
62
+ for (const k of Object.keys(rawPayload)) {
63
+ if (!allowedKeys.has(k)) {
64
+ return writeJson(res, 400, { ok: false, error: `unknown config field: ${k}` })
65
+ }
66
+ }
50
67
  try {
51
- const parsed = Config({ ...publicConfig(live()), ...payload })
68
+ const base = plainConfig(live()) || {}
69
+ delete base.enabledModels
70
+ const merged = { ...base, ...rawPayload }
71
+ const parsed = Config(merged)
52
72
  await settingsApi.replace(parsed)
53
73
  await syncProviderState(parsed)
54
74
  writeJson(res, 200, { ok: true, config: publicConfig(live()) })
@@ -77,15 +97,35 @@ export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProvider
77
97
 
78
98
  const pub = publicConfig(live())
79
99
  const targetEnvName = String(body.apiKeyEnv || pub.apiKeyEnv || DEFAULT_API_KEY_ENV).trim()
100
+
101
+ const isDefaultPattern = /^CLINEBOT_API_KEY(_[A-Z0-9]+)?$/.test(targetEnvName)
102
+ const isConfiguredEnv = targetEnvName === pub.apiKeyEnv ||
103
+ (Array.isArray(pub.accounts) && pub.accounts.some(acc => acc && acc.apiKeyEnv === targetEnvName))
104
+
105
+ if (!isDefaultPattern && !isConfiguredEnv) {
106
+ return writeJson(res, 400, {
107
+ ok: false,
108
+ error: `Disallowed apiKeyEnv: "${targetEnvName}". Must match ^CLINEBOT_API_KEY(_[A-Z0-9]+)?$ or be configured in settings.`,
109
+ })
110
+ }
111
+
80
112
  await saveCredentialKey(ctx, targetEnvName, apiKey)
81
113
 
82
114
  await syncProviderState(live())
83
115
  triggerAutoDiscover()
84
116
 
85
- const validation = await smokeChat(pub.baseUrl, apiKey, {
86
- model: pub.defaultModel,
87
- timeoutMs: 15000,
88
- })
117
+ let validation = { ok: false, error: 'Skipped validation: baseUrl is not https' }
118
+ try {
119
+ const parsedUrl = new URL(pub.baseUrl)
120
+ if (parsedUrl.protocol === 'https:') {
121
+ validation = await smokeChat(pub.baseUrl, apiKey, {
122
+ model: pub.defaultModel,
123
+ timeoutMs: 15000,
124
+ })
125
+ }
126
+ } catch {
127
+ validation = { ok: false, error: 'Invalid baseUrl' }
128
+ }
89
129
 
90
130
  writeJson(res, 200, {
91
131
  ok: true,
@@ -1,4 +1,4 @@
1
- import { publicConfig, Config } from './config.js'
1
+ import { publicConfig, plainConfig, Config } from './config.js'
2
2
  import { resolveActiveAccountKey, formatProgressBar } from './provider-sync.js'
3
3
  import {
4
4
  getAllModels,
@@ -13,11 +13,12 @@ import {
13
13
  probeHealth,
14
14
  smokeChat,
15
15
  fetchUsageLimits,
16
- recordSessionRequest,
16
+ recordSmokeTest,
17
17
  clearUsageCache,
18
18
  clearProbeCache,
19
19
  usageCache,
20
20
  sessionStats,
21
+ getLastRotation,
21
22
  } from './cline-client.js'
22
23
 
23
24
  export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderState }) {
@@ -37,11 +38,18 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
37
38
  if (subcmd === 'models') {
38
39
  const allModels = getAllModels(pub.dynamicModels)
39
40
  const disabledSet = new Set(pub.disabledModels || [])
41
+ const syncStatus = pub.planSynced
42
+ ? `✅ Verified with plan (${pub.planSyncedAt ? new Date(pub.planSyncedAt).toLocaleDateString() : 'synced'})`
43
+ : '⚠️ Not verified with plan (fallback catalog)'
40
44
  const lines = [
41
45
  '### 🎯 ClinePass Models Catalog',
46
+ `* **Plan Sync**: ${syncStatus}`,
42
47
  `* **Total models**: ${allModels.length} (${allModels.filter((m) => !disabledSet.has(m.id)).length} active)`,
43
- '',
44
48
  ]
49
+ if (pub.defaultModelWarning) {
50
+ lines.push(`* ⚠️ **Warning**: ${pub.defaultModelWarning}`)
51
+ }
52
+ lines.push('')
45
53
  for (const m of allModels) {
46
54
  const active = !disabledSet.has(m.id) ? '✅' : '❌'
47
55
  const isVis = isVisionModel(m.id, pub.dynamicModels) ? '📷 Vision' : '📝 Text'
@@ -67,6 +75,10 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
67
75
  const usageInfo = cached?.windows?.fiveHour ? ` · ⏱ ${cached.windows.fiveHour.percentUsed}% used` : ''
68
76
  lines.push(`* **${acc.label}** (\`${acc.apiKeyEnv}\`): ${statusBadge} ${pinBadge}${usageInfo}`)
69
77
  }
78
+ const lastRot = getLastRotation()
79
+ if (lastRot) {
80
+ lines.push('', `* **Last Failover**: \`${lastRot.from}\` → \`${lastRot.to}\` (${lastRot.reason})`)
81
+ }
70
82
  lines.push('', 'Switch active account: `/cline switch <env_variable_name>`')
71
83
  return lines.join('\n')
72
84
  }
@@ -80,7 +92,7 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
80
92
  clearProbeCache()
81
93
  const settingsApi = getSettingsApi()
82
94
  if (settingsApi?.replace) {
83
- const next = Config({ ...live(), activeAccount: param })
95
+ const next = Config({ ...plainConfig(live()), activeAccount: param })
84
96
  await settingsApi.replace(next)
85
97
  await syncProviderState(next)
86
98
  return `✅ Active account switched to \`${param}\``
@@ -107,7 +119,33 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
107
119
  return `❌ **Cline API Ping Failed**: ${health.error || 'Host unreachable'}`
108
120
  }
109
121
 
110
- // 6. Subcommand /cline test [model] / smoke
122
+ // 6. Subcommand /cline stats (real DSH stream metrics)
123
+ if (subcmd === 'stats' || subcmd === 'telemetry') {
124
+ const lines = [
125
+ '### 📈 DSH ClineBot Stream Telemetry',
126
+ `* **Requests**: ${sessionStats.successfulRequests} successful / ${sessionStats.totalRequests} total${sessionStats.abortedRequests ? ` (${sessionStats.abortedRequests} aborted)` : ''}${sessionStats.failedRequests ? ` (${sessionStats.failedRequests} failed)` : ''}`,
127
+ `* **Estimated Tokens**: ~${sessionStats.totalTokensEst} total (~${sessionStats.promptTokensEst} prompt, ~${sessionStats.completionTokensEst} completion)`,
128
+ `* **Last Stream Latency**: ${sessionStats.lastLatencyMs !== null ? `${sessionStats.lastLatencyMs} ms` : '—'}`,
129
+ `* **Last Request At**: ${sessionStats.lastRequestAt ? new Date(sessionStats.lastRequestAt).toLocaleTimeString() : '—'}`,
130
+ ]
131
+ if (sessionStats.lastError) {
132
+ lines.push(`* **Last Stream Error**: ${sessionStats.lastError}`)
133
+ }
134
+ if (sessionStats.byModel && Object.keys(sessionStats.byModel).length) {
135
+ lines.push('', '**By Model:**')
136
+ for (const [mId, mStats] of Object.entries(sessionStats.byModel)) {
137
+ lines.push(`* \`${mId}\`: ${mStats.requests} reqs, ~${mStats.promptTokens + mStats.completionTokens} tokens`)
138
+ }
139
+ }
140
+ if (sessionStats.lastSmoke) {
141
+ const smoke = sessionStats.lastSmoke
142
+ const smokeTime = new Date(smoke.at).toLocaleTimeString()
143
+ lines.push('', `* **Last Smoke Test**: ${smoke.ok ? '✅ Passed' : '❌ Failed'} (${smoke.latencyMs} ms at ${smokeTime}${smoke.model ? `, model: \`${smoke.model}\`` : ''})`)
144
+ }
145
+ return lines.join('\n')
146
+ }
147
+
148
+ // 7. Subcommand /cline test [model] / smoke
111
149
  if (subcmd === 'test' || subcmd === 'smoke') {
112
150
  if (param && !isSupportedModel(param, pub.dynamicModels)) {
113
151
  return `⚠️ **ClineBot**: Model \`${param}\` is not recognized. Use \`/cline models\` to see available models.`
@@ -121,12 +159,11 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
121
159
  model: modelToTest,
122
160
  timeoutMs: pub.smokeTimeoutMs,
123
161
  })
124
- recordSessionRequest({
162
+ recordSmokeTest({
125
163
  latencyMs: outcome.latencyMs,
126
164
  ok: outcome.ok,
127
165
  error: outcome.error,
128
- promptTokens: outcome.promptTokens || 5,
129
- completionTokens: outcome.completionTokens || 10,
166
+ model: modelToTest,
130
167
  })
131
168
 
132
169
  let failoverNotice = ''
@@ -170,10 +207,15 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
170
207
  `* **Host Ping**: ${health.ok ? `✅ ${health.latencyMs} ms` : '❌ Unreachable'}`,
171
208
  `* **Active Key**: \`${activeKey.envName}\` (${activeKey.source})`,
172
209
  `* **Default Model**: \`${pub.defaultModel}\``,
210
+ ]
211
+ if (pub.defaultModelWarning) {
212
+ lines.push(`* ⚠️ **Warning**: ${pub.defaultModelWarning}`)
213
+ }
214
+ lines.push(
173
215
  '',
174
216
  `**⏱ 5-Hour Rolling Limit**: ${formatProgressBar(fiveHour?.percentUsed)} (reset: ${reset5h})`,
175
217
  `**📅 Weekly Window**: ${formatProgressBar(weekly?.percentUsed)} (reset: ${resetWk})`,
176
- ]
218
+ )
177
219
 
178
220
  if (fiveHour?.percentUsed >= 95) {
179
221
  lines.push('', '🚨 **CRITICAL LIMIT**: 5-hour quota is 95%+ consumed! New requests may be throttled until reset.')
@@ -0,0 +1,78 @@
1
+ /**
2
+ * In-memory runtime session metrics for ClinePass requests in DeepSeek Harness.
3
+ */
4
+
5
+ export const sessionStats = {
6
+ totalRequests: 0,
7
+ successfulRequests: 0,
8
+ failedRequests: 0,
9
+ abortedRequests: 0,
10
+ promptTokensEst: 0,
11
+ completionTokensEst: 0,
12
+ totalTokensEst: 0,
13
+ lastLatencyMs: null,
14
+ lastRequestAt: null,
15
+ lastError: null,
16
+ byModel: {},
17
+ lastSmoke: null,
18
+ }
19
+
20
+ export function recordSessionRequest({
21
+ latencyMs,
22
+ ok,
23
+ error,
24
+ aborted = false,
25
+ promptTokens = 0,
26
+ completionTokens = 0,
27
+ model,
28
+ }) {
29
+ sessionStats.totalRequests += 1
30
+ if (aborted) {
31
+ sessionStats.abortedRequests = (sessionStats.abortedRequests || 0) + 1
32
+ } else if (ok) {
33
+ sessionStats.successfulRequests += 1
34
+ sessionStats.lastLatencyMs = typeof latencyMs === 'number' ? latencyMs : null
35
+ sessionStats.lastError = null
36
+ } else {
37
+ sessionStats.failedRequests += 1
38
+ sessionStats.lastError = error || 'Request failed'
39
+ }
40
+ sessionStats.lastRequestAt = Date.now()
41
+ sessionStats.promptTokensEst += Number(promptTokens) || 0
42
+ sessionStats.completionTokensEst += Number(completionTokens) || 0
43
+ sessionStats.totalTokensEst += (Number(promptTokens) || 0) + (Number(completionTokens) || 0)
44
+
45
+ if (model) {
46
+ if (!sessionStats.byModel) sessionStats.byModel = {}
47
+ const m = sessionStats.byModel[model] || { requests: 0, promptTokens: 0, completionTokens: 0 }
48
+ m.requests += 1
49
+ m.promptTokens += Number(promptTokens) || 0
50
+ m.completionTokens += Number(completionTokens) || 0
51
+ sessionStats.byModel[model] = m
52
+ }
53
+ }
54
+
55
+ export function recordSmokeTest({ latencyMs, ok, error, model }) {
56
+ sessionStats.lastSmoke = {
57
+ at: Date.now(),
58
+ latencyMs: typeof latencyMs === 'number' ? latencyMs : null,
59
+ ok: Boolean(ok),
60
+ error: error || null,
61
+ model: model || null,
62
+ }
63
+ }
64
+
65
+ export function resetSessionStats() {
66
+ sessionStats.totalRequests = 0
67
+ sessionStats.successfulRequests = 0
68
+ sessionStats.failedRequests = 0
69
+ sessionStats.abortedRequests = 0
70
+ sessionStats.promptTokensEst = 0
71
+ sessionStats.completionTokensEst = 0
72
+ sessionStats.totalTokensEst = 0
73
+ sessionStats.lastLatencyMs = null
74
+ sessionStats.lastRequestAt = null
75
+ sessionStats.lastError = null
76
+ sessionStats.byModel = {}
77
+ sessionStats.lastSmoke = null
78
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.3.24",
3
+ "version": "0.4.0",
4
4
  "description": "DeepSeek Harness companion for ClineBot / ClinePass: dynamic subscription models sync, quota exhaustion warnings, session metrics, dedicated settings page, live usage limits, and /cline slash-command.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -63,6 +63,9 @@
63
63
  "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
64
64
  "@deepseek-ai/schemastery": "^3.18.1"
65
65
  },
66
+ "devDependencies": {
67
+ "@deepseek-ai/schemastery": "^3.18.1"
68
+ },
66
69
  "homepage": "https://github.com/GooDAnDReaDY/dsh-clinebot#readme",
67
70
  "bugs": {
68
71
  "url": "https://github.com/GooDAnDReaDY/dsh-clinebot/issues"