@goodandready/dsh-clinebot 0.2.1 → 0.3.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.
package/lib/index.js CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  PROVIDER_DISPLAY_NAME,
9
9
  getAllModels,
10
10
  getDefaultModelIds,
11
- validateCustomModel,
11
+ parsePlanIncludedModels,
12
12
  } from './models.js'
13
13
  import {
14
14
  DEFAULT_BASE_URL,
@@ -22,6 +22,9 @@ import {
22
22
  probeHealth,
23
23
  smokeChat,
24
24
  buildPiAiProvider,
25
+ sessionStats,
26
+ recordSessionRequest,
27
+ resetSessionStats,
25
28
  } from './cline-client.js'
26
29
 
27
30
  export const name = '@goodandready/dsh-clinebot'
@@ -30,6 +33,8 @@ export const inject = ['settings', 'webServer', 'credentials']
30
33
  export const NS = 'dsh-clinebot'
31
34
  export const LLM_PI_AI_NS = 'llm-pi-ai'
32
35
 
36
+ export { sessionStats, recordSessionRequest, resetSessionStats }
37
+
33
38
  export const Config = z.object({
34
39
  enabled: z.boolean().default(true)
35
40
  .description('When true, ClineBot is registered as a model provider in DSH.'),
@@ -41,7 +46,7 @@ export const Config = z.object({
41
46
  .description('Default model ID for chat and smoke tests.'),
42
47
  enabledModels: z.array(z.string()).default(getDefaultModelIds())
43
48
  .description('List of model IDs enabled for selection in DSH.'),
44
- customModels: z.array(z.object({
49
+ dynamicModels: z.array(z.object({
45
50
  id: z.string(),
46
51
  name: z.string(),
47
52
  description: z.string().default(''),
@@ -49,9 +54,9 @@ export const Config = z.object({
49
54
  maxTokens: z.number().default(8192),
50
55
  input: z.array(z.string()).default(['text']),
51
56
  category: z.string().default('general'),
52
- isCustom: z.boolean().default(true),
57
+ isCustom: z.boolean().default(false),
53
58
  })).default([])
54
- .description('User-added custom models from the ClinePass subscription.'),
59
+ .description('Models automatically discovered from the official ClinePass subscription plan.'),
55
60
  timeoutMs: z.number().default(DEFAULT_TIMEOUT_MS)
56
61
  .description('HTTP probe timeout in milliseconds.'),
57
62
  smokeTimeoutMs: z.number().default(DEFAULT_SMOKE_TIMEOUT_MS)
@@ -59,14 +64,14 @@ export const Config = z.object({
59
64
  })
60
65
 
61
66
  function publicConfig(cfg) {
62
- const custom = Array.isArray(cfg?.customModels) ? cfg.customModels : []
63
- const allDefaultIds = getDefaultModelIds(custom)
67
+ const dynamic = Array.isArray(cfg?.dynamicModels) ? cfg.dynamicModels : []
68
+ const allDefaultIds = getDefaultModelIds(dynamic)
64
69
  return {
65
70
  enabled: !!cfg?.enabled,
66
71
  baseUrl: normalizeBaseUrl(cfg?.baseUrl),
67
72
  apiKeyEnv: cfg?.apiKeyEnv || DEFAULT_API_KEY_ENV,
68
73
  defaultModel: cfg?.defaultModel || DEFAULT_MODEL_ID,
69
- customModels: custom,
74
+ dynamicModels: dynamic,
70
75
  enabledModels: Array.isArray(cfg?.enabledModels) && cfg.enabledModels.length
71
76
  ? cfg.enabledModels
72
77
  : allDefaultIds,
@@ -112,13 +117,32 @@ async function buildStatus(ctx, cfg) {
112
117
  const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
113
118
  const health = await probeHealth(pub.baseUrl, { timeoutMs: pub.timeoutMs })
114
119
  const isRegistered = await checkRegisteredInPiAi(ctx)
115
- const allModels = getAllModels(pub.customModels)
120
+ const allModels = getAllModels(pub.dynamicModels)
116
121
 
117
122
  let usage = null
118
123
  if (key.value) {
119
124
  usage = await fetchUsageLimits(pub.baseUrl, key.value, { timeoutMs: pub.timeoutMs }).catch(() => null)
120
125
  }
121
126
 
127
+ // Evaluate warning state
128
+ let quotaWarning = null
129
+ if (usage?.windows?.fiveHour) {
130
+ const pct = usage.windows.fiveHour.percentUsed
131
+ if (pct >= 95) {
132
+ quotaWarning = {
133
+ level: 'exhausted',
134
+ message: `5-часовой лимит почти полностью исчерпан (${pct}%). Новые запросы могут отклоняться провайдером до сброса.`,
135
+ resetsAt: usage.windows.fiveHour.resetsAt,
136
+ }
137
+ } else if (pct >= 80) {
138
+ quotaWarning = {
139
+ level: 'warning',
140
+ message: `Внимание: израсходовано ${pct}% 5-часового скользящего лимита.`,
141
+ resetsAt: usage.windows.fiveHour.resetsAt,
142
+ }
143
+ }
144
+ }
145
+
122
146
  return {
123
147
  ok: true,
124
148
  providerId: PROVIDER_ID,
@@ -131,27 +155,29 @@ async function buildStatus(ctx, cfg) {
131
155
  },
132
156
  health,
133
157
  usage,
158
+ quotaWarning,
159
+ sessionStats: { ...sessionStats },
134
160
  isRegistered,
135
161
  availableModels: allModels,
136
162
  }
137
163
  }
138
164
 
139
- async function upsertPiAiProvider(ctx, cfg, customModelIds) {
165
+ async function upsertPiAiProvider(ctx, cfg, dynamicModelIds) {
140
166
  const settings = ctx?.get?.('settings')
141
167
  if (!settings?.mutate) {
142
168
  throw new Error('DSH settings service unavailable')
143
169
  }
144
170
 
145
171
  const pub = publicConfig(cfg)
146
- const allModels = getAllModels(pub.customModels)
147
- const selectedIds = new Set(customModelIds || pub.enabledModels)
172
+ const allModels = getAllModels(pub.dynamicModels)
173
+ const selectedIds = new Set(dynamicModelIds || pub.enabledModels)
148
174
  const modelsToRegister = allModels.filter((m) => selectedIds.has(m.id))
149
175
 
150
176
  const providerObj = buildPiAiProvider({
151
177
  baseUrl: pub.baseUrl,
152
178
  apiKeyEnv: pub.apiKeyEnv,
153
179
  models: modelsToRegister.length ? modelsToRegister : allModels,
154
- customModels: pub.customModels,
180
+ customModels: pub.dynamicModels,
155
181
  displayName: PROVIDER_DISPLAY_NAME,
156
182
  })
157
183
 
@@ -387,96 +413,82 @@ export function apply(ctx, config) {
387
413
  model: modelToTest,
388
414
  timeoutMs: pub.smokeTimeoutMs,
389
415
  })
416
+ recordSessionRequest({
417
+ latencyMs: outcome.latencyMs,
418
+ ok: outcome.ok,
419
+ error: outcome.error,
420
+ promptTokens: 5,
421
+ completionTokens: 10,
422
+ })
390
423
  writeJson(res, outcome.ok ? 200 : 502, outcome)
391
424
  } catch (err) {
425
+ recordSessionRequest({ ok: false, error: String(err?.message || err) })
392
426
  writeJson(res, 500, { ok: false, error: String(err?.message || err) })
393
427
  }
394
428
  },
395
429
  }), 'dsh-clinebot: /smoke')
396
430
 
397
- // 8. POST & DELETE /dsh-clinebot/models/custom — add/update or remove custom model
431
+ // 8. POST /dsh-clinebot/models/sync — sync real models from official ClinePass subscription plan
398
432
  ctx.effect(() => ctx.webServer.register({
399
433
  kind: 'exact',
400
- path: '/dsh-clinebot/models/custom',
434
+ path: '/dsh-clinebot/models/sync',
401
435
  handler: async (req, res) => {
402
436
  if (!isTrustedSettingsRequest(req)) {
403
437
  return writeJson(res, 403, { ok: false, error: 'Forbidden' })
404
438
  }
405
- if (req.method === 'POST') {
406
- try {
407
- const bodyBuf = await readBody(req)
408
- let body = {}
409
- try { body = JSON.parse(bodyBuf.toString('utf8')) } catch {}
410
-
411
- const validated = validateCustomModel(body)
412
- if (!validated.ok) {
413
- return writeJson(res, 400, { ok: false, error: validated.error })
414
- }
415
-
416
- const currentCustom = Array.isArray(live().customModels) ? [...live().customModels] : []
417
- const existingIdx = currentCustom.findIndex((m) => m.id === validated.model.id)
418
- if (existingIdx >= 0) {
419
- currentCustom[existingIdx] = validated.model
420
- } else {
421
- currentCustom.push(validated.model)
422
- }
439
+ if (req.method !== 'POST') {
440
+ return writeJson(res, 405, { ok: false, error: 'POST only' })
441
+ }
442
+ try {
443
+ const pub = publicConfig(live())
444
+ const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
445
+ if (!key.value) {
446
+ return writeJson(res, 400, { ok: false, error: 'API key not configured' })
447
+ }
423
448
 
424
- const currentEnabled = new Set(live().enabledModels || getDefaultModelIds())
425
- currentEnabled.add(validated.model.id)
426
-
427
- if (settingsApi?.replace) {
428
- const next = Config({
429
- ...live(),
430
- customModels: currentCustom,
431
- enabledModels: Array.from(currentEnabled),
432
- })
433
- await settingsApi.replace(next)
434
- if (await checkRegisteredInPiAi(ctx)) {
435
- await upsertPiAiProvider(ctx, next, Array.from(currentEnabled))
436
- }
437
- }
449
+ const usageData = await fetchUsageLimits(pub.baseUrl, key.value, {
450
+ timeoutMs: pub.timeoutMs,
451
+ bypassCache: true,
452
+ })
438
453
 
439
- return writeJson(res, 200, { ok: true, model: validated.model, customModels: currentCustom })
440
- } catch (err) {
441
- return writeJson(res, 500, { ok: false, error: String(err?.message || err) })
454
+ if (!usageData.ok) {
455
+ return writeJson(res, 502, { ok: false, error: usageData.error || 'Failed to fetch plan models' })
442
456
  }
443
- }
444
457
 
445
- if (req.method === 'DELETE') {
446
- try {
447
- const bodyBuf = await readBody(req)
448
- let body = {}
449
- try { body = JSON.parse(bodyBuf.toString('utf8')) } catch {}
458
+ const dynamicModels = Array.isArray(usageData.dynamicModels) ? usageData.dynamicModels : []
459
+ const allModels = getAllModels(dynamicModels)
460
+ const allIds = allModels.map((m) => m.id)
450
461
 
451
- const modelId = String(body.id || '').trim()
452
- if (!modelId) {
453
- return writeJson(res, 400, { ok: false, error: 'Missing model ID' })
454
- }
462
+ // Preserve currently enabled models, plus add any newly discovered ones
463
+ const existingEnabled = new Set(live().enabledModels || getDefaultModelIds())
464
+ for (const m of allModels) {
465
+ existingEnabled.add(m.id)
466
+ }
455
467
 
456
- const currentCustom = (live().customModels || []).filter((m) => m.id !== modelId)
457
- const currentEnabled = (live().enabledModels || []).filter((id) => id !== modelId)
458
-
459
- if (settingsApi?.replace) {
460
- const next = Config({
461
- ...live(),
462
- customModels: currentCustom,
463
- enabledModels: currentEnabled,
464
- })
465
- await settingsApi.replace(next)
466
- if (await checkRegisteredInPiAi(ctx)) {
467
- await upsertPiAiProvider(ctx, next, currentEnabled)
468
- }
468
+ if (settingsApi?.replace) {
469
+ const next = Config({
470
+ ...live(),
471
+ dynamicModels,
472
+ enabledModels: Array.from(existingEnabled),
473
+ })
474
+ await settingsApi.replace(next)
475
+ if (await checkRegisteredInPiAi(ctx)) {
476
+ await upsertPiAiProvider(ctx, next, Array.from(existingEnabled))
469
477
  }
470
-
471
- return writeJson(res, 200, { ok: true, removed: modelId, customModels: currentCustom })
472
- } catch (err) {
473
- return writeJson(res, 500, { ok: false, error: String(err?.message || err) })
474
478
  }
475
- }
476
479
 
477
- return writeJson(res, 405, { ok: false, error: 'POST or DELETE' })
480
+ return writeJson(res, 200, {
481
+ ok: true,
482
+ plan: usageData.plan,
483
+ discoveredCount: dynamicModels.length,
484
+ totalModelsCount: allModels.length,
485
+ models: allModels,
486
+ })
487
+ } catch (err) {
488
+ return writeJson(res, 500, { ok: false, error: String(err?.message || err) })
489
+ }
478
490
  },
479
- }), 'dsh-clinebot: /models/custom')
491
+ }), 'dsh-clinebot: /models/sync')
480
492
 
481
493
  // 9. POST /dsh-clinebot/models/toggle — toggle enabled status in picker
482
494
  ctx.effect(() => ctx.webServer.register({
@@ -516,7 +528,7 @@ export function apply(ctx, config) {
516
528
 
517
529
  const unregister = commands.register({
518
530
  name: 'cline',
519
- description: 'Check ClinePass subscription quota, rate limits and latency',
531
+ description: 'Check ClinePass subscription quota, rate limits, session stats and latency',
520
532
  execute: async () => {
521
533
  const pub = publicConfig(live())
522
534
  const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
@@ -544,6 +556,16 @@ export function apply(ctx, config) {
544
556
  `**📅 Недельное окно**: ${formatProgressBar(weekly?.percentUsed)} (сброс: ${resetWk})`,
545
557
  ]
546
558
 
559
+ if (fiveHour?.percentUsed >= 95) {
560
+ lines.push('', '🚨 **КРИТИЧЕСКИЙ ЛИМИТ**: 5-часовая квота израсходована на 95%+. Запросы могут быть заблокированы до сброса!')
561
+ } else if (fiveHour?.percentUsed >= 80) {
562
+ lines.push('', '⚠️ **Внимание**: 5-часовая квота израсходована на ' + fiveHour.percentUsed + '%.')
563
+ }
564
+
565
+ if (sessionStats.totalRequests > 0) {
566
+ lines.push('', `**📊 Сессия DSH**: ${sessionStats.successfulRequests}/${sessionStats.totalRequests} успешных запросов, ~${sessionStats.totalTokensEst} токенов`)
567
+ }
568
+
547
569
  if (usage?.user?.email) {
548
570
  lines.push(`* **Аккаунт**: \`${usage.user.email}\``)
549
571
  }
@@ -559,6 +581,8 @@ export function apply(ctx, config) {
559
581
  getStatus: () => buildStatus(ctx, live()),
560
582
  registerProvider: (models) => upsertPiAiProvider(ctx, live(), models),
561
583
  unregisterProvider: () => removePiAiProvider(ctx),
584
+ recordRequestMetrics: (metrics) => recordSessionRequest(metrics),
585
+ getSessionStats: () => ({ ...sessionStats }),
562
586
  getUsageLimits: async () => {
563
587
  const pub = publicConfig(live())
564
588
  const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
@@ -567,7 +591,15 @@ export function apply(ctx, config) {
567
591
  runSmokeTest: async (model) => {
568
592
  const pub = publicConfig(live())
569
593
  const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
570
- return smokeChat(pub.baseUrl, key.value, { model: model || pub.defaultModel })
594
+ const res = await smokeChat(pub.baseUrl, key.value, { model: model || pub.defaultModel })
595
+ recordSessionRequest({
596
+ latencyMs: res.latencyMs,
597
+ ok: res.ok,
598
+ error: res.error,
599
+ promptTokens: 5,
600
+ completionTokens: 10,
601
+ })
602
+ return res
571
603
  },
572
604
  }
573
605
  }
package/lib/models.js CHANGED
@@ -135,60 +135,69 @@ export const CLINE_MODELS = Object.freeze([
135
135
  ])
136
136
 
137
137
  /**
138
- * Validate a custom model entry.
138
+ * Parse human-readable included models string from ClinePass plan features.
139
+ * Example: "Includes Kimi K3, GLM 5.2, Kimi K2.6, Kimi K2.7 Code, Mimo v2.5, Mimo v2.5 Pro, Minimax M3, Qwen3.7 Plus, Qwen3.7 Max, DeepSeek V4 Pro, and DeepSeek V4 Flash"
139
140
  */
140
- export function validateCustomModel(raw) {
141
- if (!raw || typeof raw !== 'object') {
142
- return { ok: false, error: 'Model descriptor must be an object' }
143
- }
141
+ export function parsePlanIncludedModels(includedText) {
142
+ if (!includedText || typeof includedText !== 'string') return []
143
+ const clean = includedText
144
+ .replace(/^includes\s+/i, '')
145
+ .replace(/\band\b/gi, ',')
146
+ .replace(/\./g, '')
147
+ .trim()
144
148
 
145
- let id = String(raw.id || '').trim()
146
- if (!id) {
147
- return { ok: false, error: 'Model ID is required' }
148
- }
149
- // If user didn't prefix with cline-pass/, allow both or auto-prefix if helpful
150
- if (!id.startsWith('cline-pass/') && !id.includes('/')) {
151
- id = `cline-pass/${id}`
152
- }
149
+ const parts = clean
150
+ .split(',')
151
+ .map((p) => p.trim())
152
+ .filter(Boolean)
153
153
 
154
- const name = String(raw.name || '').trim() || id.split('/').pop() || id
155
- const contextLength = Number(raw.contextLength) > 0 ? Number(raw.contextLength) : 200000
156
- const maxTokens = Number(raw.maxTokens) > 0 ? Number(raw.maxTokens) : 8192
157
- const hasVision = Boolean(raw.hasVision || raw.input?.includes('vision') || raw.input?.includes('image'))
158
- const input = hasVision ? ['text', 'image'] : ['text']
159
- const category = ['coding', 'reasoning', 'multimodal', 'general'].includes(raw.category)
160
- ? raw.category
161
- : hasVision ? 'multimodal' : 'general'
154
+ const normalize = (s) =>
155
+ String(s || '')
156
+ .toLowerCase()
157
+ .replace(/[\s._-]+/g, '')
162
158
 
163
- return {
164
- ok: true,
165
- model: {
166
- id,
167
- name,
168
- description: String(raw.description || 'User added custom model').trim(),
169
- contextLength,
170
- maxTokens,
171
- input,
172
- category,
173
- recommended: false,
174
- isCustom: true,
175
- },
159
+ const matched = []
160
+ for (const name of parts) {
161
+ const targetNorm = normalize(name)
162
+ const found = CLINE_MODELS.find(
163
+ (m) =>
164
+ normalize(m.name) === targetNorm ||
165
+ normalize(m.id.replace(/^cline-pass\//, '')) === targetNorm
166
+ )
167
+ if (found) {
168
+ matched.push(found)
169
+ } else {
170
+ // Dynamic fallback for newly introduced models mentioned in plan
171
+ const idPart = name.toLowerCase().replace(/[\s_]+/g, '-')
172
+ matched.push({
173
+ id: `cline-pass/${idPart}`,
174
+ name,
175
+ description: `Official ClinePass subscription model: ${name}`,
176
+ contextLength: 200000,
177
+ maxTokens: 8192,
178
+ input: ['text'],
179
+ category: 'general',
180
+ recommended: false,
181
+ isCustom: false,
182
+ })
183
+ }
176
184
  }
185
+
186
+ return matched
177
187
  }
178
188
 
179
189
  /**
180
- * Get all models combining built-in official models with custom user additions.
190
+ * Get all models from known catalog.
181
191
  */
182
- export function getAllModels(customModels = []) {
192
+ export function getAllModels(dynamicModels = []) {
183
193
  const result = [...CLINE_MODELS]
184
194
  const seenIds = new Set(result.map((m) => m.id))
185
195
 
186
- if (Array.isArray(customModels)) {
187
- for (const item of customModels) {
188
- const valid = validateCustomModel(item)
189
- if (valid.ok && !seenIds.has(valid.model.id)) {
190
- result.push(valid.model)
191
- seenIds.add(valid.model.id)
196
+ if (Array.isArray(dynamicModels)) {
197
+ for (const item of dynamicModels) {
198
+ if (item && item.id && !seenIds.has(item.id)) {
199
+ result.push(item)
200
+ seenIds.add(item.id)
192
201
  }
193
202
  }
194
203
  }
@@ -196,15 +205,16 @@ export function getAllModels(customModels = []) {
196
205
  return result
197
206
  }
198
207
 
199
- export function findModel(id, customModels = []) {
200
- const all = getAllModels(customModels)
208
+ export function findModel(id, dynamicModels = []) {
209
+ const all = getAllModels(dynamicModels)
201
210
  return all.find((m) => m.id === id) || null
202
211
  }
203
212
 
204
- export function isSupportedModel(id, customModels = []) {
205
- return findModel(id, customModels) !== null
213
+ export function isSupportedModel(id, dynamicModels = []) {
214
+ return findModel(id, dynamicModels) !== null
206
215
  }
207
216
 
208
- export function getDefaultModelIds(customModels = []) {
209
- return getAllModels(customModels).map((m) => m.id)
217
+ export function getDefaultModelIds(dynamicModels = []) {
218
+ return getAllModels(dynamicModels).map((m) => m.id)
210
219
  }
220
+
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.2.1",
4
- "description": "DeepSeek Harness companion for ClineBot / ClinePass: dedicated settings page, live quota usage limits dashboard, in-UI credentials saving, static & custom models catalog, model picker control, and /cline slash-command.",
3
+ "version": "0.3.0",
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",
7
7
  "main": "./lib/index.js",