@goodandready/dsh-clinebot 0.2.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/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/README.ru.md +131 -0
- package/README.zh.md +68 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +895 -0
- package/lib/cline-client.js +342 -0
- package/lib/http.js +34 -0
- package/lib/index.js +573 -0
- package/lib/models.js +210 -0
- package/package.json +68 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
import z from '@deepseek-ai/schemastery'
|
|
2
|
+
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
3
|
+
import { writeJson, readBody, isTrustedSettingsRequest } from './http.js'
|
|
4
|
+
import {
|
|
5
|
+
CLINE_MODELS,
|
|
6
|
+
DEFAULT_MODEL_ID,
|
|
7
|
+
PROVIDER_ID,
|
|
8
|
+
PROVIDER_DISPLAY_NAME,
|
|
9
|
+
getAllModels,
|
|
10
|
+
getDefaultModelIds,
|
|
11
|
+
validateCustomModel,
|
|
12
|
+
} from './models.js'
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_BASE_URL,
|
|
15
|
+
DEFAULT_API_KEY_ENV,
|
|
16
|
+
DEFAULT_TIMEOUT_MS,
|
|
17
|
+
DEFAULT_SMOKE_TIMEOUT_MS,
|
|
18
|
+
normalizeBaseUrl,
|
|
19
|
+
resolveApiKey,
|
|
20
|
+
saveCredentialKey,
|
|
21
|
+
fetchUsageLimits,
|
|
22
|
+
probeHealth,
|
|
23
|
+
smokeChat,
|
|
24
|
+
buildPiAiProvider,
|
|
25
|
+
} from './cline-client.js'
|
|
26
|
+
|
|
27
|
+
export const name = '@goodandready/dsh-clinebot'
|
|
28
|
+
export const inject = ['settings', 'webServer', 'credentials']
|
|
29
|
+
|
|
30
|
+
export const NS = 'dsh-clinebot'
|
|
31
|
+
export const LLM_PI_AI_NS = 'llm-pi-ai'
|
|
32
|
+
|
|
33
|
+
export const Config = z.object({
|
|
34
|
+
enabled: z.boolean().default(true)
|
|
35
|
+
.description('When true, ClineBot is registered as a model provider in DSH.'),
|
|
36
|
+
baseUrl: z.string().default(DEFAULT_BASE_URL)
|
|
37
|
+
.description('Base API URL (default: https://api.cline.bot/api/v1).'),
|
|
38
|
+
apiKeyEnv: z.string().default(DEFAULT_API_KEY_ENV)
|
|
39
|
+
.description('Credential / env name containing the ClinePass API key (never store key directly here).'),
|
|
40
|
+
defaultModel: z.string().default(DEFAULT_MODEL_ID)
|
|
41
|
+
.description('Default model ID for chat and smoke tests.'),
|
|
42
|
+
enabledModels: z.array(z.string()).default(getDefaultModelIds())
|
|
43
|
+
.description('List of model IDs enabled for selection in DSH.'),
|
|
44
|
+
customModels: z.array(z.object({
|
|
45
|
+
id: z.string(),
|
|
46
|
+
name: z.string(),
|
|
47
|
+
description: z.string().default(''),
|
|
48
|
+
contextLength: z.number().default(200000),
|
|
49
|
+
maxTokens: z.number().default(8192),
|
|
50
|
+
input: z.array(z.string()).default(['text']),
|
|
51
|
+
category: z.string().default('general'),
|
|
52
|
+
isCustom: z.boolean().default(true),
|
|
53
|
+
})).default([])
|
|
54
|
+
.description('User-added custom models from the ClinePass subscription.'),
|
|
55
|
+
timeoutMs: z.number().default(DEFAULT_TIMEOUT_MS)
|
|
56
|
+
.description('HTTP probe timeout in milliseconds.'),
|
|
57
|
+
smokeTimeoutMs: z.number().default(DEFAULT_SMOKE_TIMEOUT_MS)
|
|
58
|
+
.description('Timeout for smoke chat completions in milliseconds.'),
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
function publicConfig(cfg) {
|
|
62
|
+
const custom = Array.isArray(cfg?.customModels) ? cfg.customModels : []
|
|
63
|
+
const allDefaultIds = getDefaultModelIds(custom)
|
|
64
|
+
return {
|
|
65
|
+
enabled: !!cfg?.enabled,
|
|
66
|
+
baseUrl: normalizeBaseUrl(cfg?.baseUrl),
|
|
67
|
+
apiKeyEnv: cfg?.apiKeyEnv || DEFAULT_API_KEY_ENV,
|
|
68
|
+
defaultModel: cfg?.defaultModel || DEFAULT_MODEL_ID,
|
|
69
|
+
customModels: custom,
|
|
70
|
+
enabledModels: Array.isArray(cfg?.enabledModels) && cfg.enabledModels.length
|
|
71
|
+
? cfg.enabledModels
|
|
72
|
+
: allDefaultIds,
|
|
73
|
+
timeoutMs: Number(cfg?.timeoutMs) || DEFAULT_TIMEOUT_MS,
|
|
74
|
+
smokeTimeoutMs: Number(cfg?.smokeTimeoutMs) || DEFAULT_SMOKE_TIMEOUT_MS,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function resolveKeyValue(ctx, apiKeyEnv) {
|
|
79
|
+
const refName = String(apiKeyEnv || DEFAULT_API_KEY_ENV).trim() || DEFAULT_API_KEY_ENV
|
|
80
|
+
if (ctx?.credentials && typeof ctx.credentials.resolve === 'function') {
|
|
81
|
+
try {
|
|
82
|
+
const hit = await ctx.credentials.resolve(credentialRef(refName))
|
|
83
|
+
if (hit?.value) {
|
|
84
|
+
return { envName: refName, value: hit.value, source: 'credentials' }
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
/* credentials service miss, fall through */
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const fromEnv = resolveApiKey(refName)
|
|
92
|
+
if (fromEnv.value) {
|
|
93
|
+
return { ...fromEnv, source: 'env' }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { envName: refName, value: '', source: 'none' }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function checkRegisteredInPiAi(ctx) {
|
|
100
|
+
const settings = ctx?.get?.('settings')
|
|
101
|
+
if (!settings?.get) return false
|
|
102
|
+
try {
|
|
103
|
+
const piAi = settings.get(LLM_PI_AI_NS)
|
|
104
|
+
return !!piAi?.providers?.[PROVIDER_ID]
|
|
105
|
+
} catch {
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function buildStatus(ctx, cfg) {
|
|
111
|
+
const pub = publicConfig(cfg)
|
|
112
|
+
const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
|
|
113
|
+
const health = await probeHealth(pub.baseUrl, { timeoutMs: pub.timeoutMs })
|
|
114
|
+
const isRegistered = await checkRegisteredInPiAi(ctx)
|
|
115
|
+
const allModels = getAllModels(pub.customModels)
|
|
116
|
+
|
|
117
|
+
let usage = null
|
|
118
|
+
if (key.value) {
|
|
119
|
+
usage = await fetchUsageLimits(pub.baseUrl, key.value, { timeoutMs: pub.timeoutMs }).catch(() => null)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
ok: true,
|
|
124
|
+
providerId: PROVIDER_ID,
|
|
125
|
+
displayName: PROVIDER_DISPLAY_NAME,
|
|
126
|
+
config: pub,
|
|
127
|
+
key: {
|
|
128
|
+
envName: key.envName,
|
|
129
|
+
present: !!key.value,
|
|
130
|
+
source: key.source,
|
|
131
|
+
},
|
|
132
|
+
health,
|
|
133
|
+
usage,
|
|
134
|
+
isRegistered,
|
|
135
|
+
availableModels: allModels,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function upsertPiAiProvider(ctx, cfg, customModelIds) {
|
|
140
|
+
const settings = ctx?.get?.('settings')
|
|
141
|
+
if (!settings?.mutate) {
|
|
142
|
+
throw new Error('DSH settings service unavailable')
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const pub = publicConfig(cfg)
|
|
146
|
+
const allModels = getAllModels(pub.customModels)
|
|
147
|
+
const selectedIds = new Set(customModelIds || pub.enabledModels)
|
|
148
|
+
const modelsToRegister = allModels.filter((m) => selectedIds.has(m.id))
|
|
149
|
+
|
|
150
|
+
const providerObj = buildPiAiProvider({
|
|
151
|
+
baseUrl: pub.baseUrl,
|
|
152
|
+
apiKeyEnv: pub.apiKeyEnv,
|
|
153
|
+
models: modelsToRegister.length ? modelsToRegister : allModels,
|
|
154
|
+
customModels: pub.customModels,
|
|
155
|
+
displayName: PROVIDER_DISPLAY_NAME,
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
await settings.mutate(LLM_PI_AI_NS, [
|
|
159
|
+
{
|
|
160
|
+
op: 'set',
|
|
161
|
+
path: ['providers', PROVIDER_ID],
|
|
162
|
+
value: providerObj,
|
|
163
|
+
},
|
|
164
|
+
])
|
|
165
|
+
|
|
166
|
+
return providerObj
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function removePiAiProvider(ctx) {
|
|
170
|
+
const settings = ctx?.get?.('settings')
|
|
171
|
+
if (!settings?.mutate) {
|
|
172
|
+
throw new Error('DSH settings service unavailable')
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
await settings.mutate(LLM_PI_AI_NS, [
|
|
176
|
+
{
|
|
177
|
+
op: 'remove',
|
|
178
|
+
path: ['providers', PROVIDER_ID],
|
|
179
|
+
},
|
|
180
|
+
])
|
|
181
|
+
return { ok: true }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function formatProgressBar(pct, totalWidth = 10) {
|
|
185
|
+
const clamped = Math.max(0, Math.min(100, pct || 0))
|
|
186
|
+
const filled = Math.round((clamped / 100) * totalWidth)
|
|
187
|
+
const empty = Math.max(0, totalWidth - filled)
|
|
188
|
+
return `[${'█'.repeat(filled)}${'░'.repeat(empty)}] ${clamped}%`
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function apply(ctx, config) {
|
|
192
|
+
let liveCfg = Config(structuredClone(config || {}))
|
|
193
|
+
let settingsApi
|
|
194
|
+
|
|
195
|
+
const settingsService = ctx.get('settings')
|
|
196
|
+
if (typeof settingsService?.register === 'function') {
|
|
197
|
+
const scope = settingsService.register(NS, Config, { base: config })
|
|
198
|
+
settingsApi = scope
|
|
199
|
+
liveCfg = Config(scope.get() ?? config)
|
|
200
|
+
ctx.effect(() => scope.watch((next) => {
|
|
201
|
+
liveCfg = Config(next ?? config)
|
|
202
|
+
}), 'dsh-clinebot: settings')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const live = () => liveCfg
|
|
206
|
+
|
|
207
|
+
// Web server HTTP route handlers
|
|
208
|
+
if (ctx.webServer?.register) {
|
|
209
|
+
// 1. GET /dsh-clinebot/status
|
|
210
|
+
ctx.effect(() => ctx.webServer.register({
|
|
211
|
+
kind: 'exact',
|
|
212
|
+
path: '/dsh-clinebot/status',
|
|
213
|
+
handler: async (req, res) => {
|
|
214
|
+
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
|
|
215
|
+
try {
|
|
216
|
+
const st = await buildStatus(ctx, live())
|
|
217
|
+
writeJson(res, 200, st)
|
|
218
|
+
} catch (err) {
|
|
219
|
+
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
}), 'dsh-clinebot: /status')
|
|
223
|
+
|
|
224
|
+
// 2. GET & PUT /dsh-clinebot/config
|
|
225
|
+
ctx.effect(() => ctx.webServer.register({
|
|
226
|
+
kind: 'exact',
|
|
227
|
+
path: '/dsh-clinebot/config',
|
|
228
|
+
handler: async (req, res) => {
|
|
229
|
+
if (req.method === 'GET') {
|
|
230
|
+
return writeJson(res, 200, { ok: true, config: publicConfig(live()) })
|
|
231
|
+
}
|
|
232
|
+
if (req.method !== 'PUT') {
|
|
233
|
+
return writeJson(res, 405, { ok: false, error: 'GET or PUT' })
|
|
234
|
+
}
|
|
235
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
236
|
+
return writeJson(res, 403, { ok: false, error: 'same-origin only' })
|
|
237
|
+
}
|
|
238
|
+
if (!settingsApi) {
|
|
239
|
+
return writeJson(res, 503, { ok: false, error: 'settings not ready' })
|
|
240
|
+
}
|
|
241
|
+
let payload
|
|
242
|
+
try {
|
|
243
|
+
payload = JSON.parse((await readBody(req)).toString('utf8') || '{}')
|
|
244
|
+
} catch {
|
|
245
|
+
return writeJson(res, 400, { ok: false, error: 'invalid json' })
|
|
246
|
+
}
|
|
247
|
+
if (payload && typeof payload.config === 'object') payload = payload.config
|
|
248
|
+
try {
|
|
249
|
+
const parsed = Config({ ...publicConfig(live()), ...payload })
|
|
250
|
+
await settingsApi.replace(parsed)
|
|
251
|
+
writeJson(res, 200, { ok: true, config: publicConfig(live()) })
|
|
252
|
+
} catch (e) {
|
|
253
|
+
writeJson(res, 400, { ok: false, error: String(e?.message || e) })
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
}), 'dsh-clinebot: /config')
|
|
257
|
+
|
|
258
|
+
// 3. POST /dsh-clinebot/save-key — direct saving into DSH credentials service
|
|
259
|
+
ctx.effect(() => ctx.webServer.register({
|
|
260
|
+
kind: 'exact',
|
|
261
|
+
path: '/dsh-clinebot/save-key',
|
|
262
|
+
handler: async (req, res) => {
|
|
263
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
264
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
265
|
+
return writeJson(res, 403, { ok: false, error: 'Forbidden' })
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
const bodyBuf = await readBody(req)
|
|
269
|
+
let body = {}
|
|
270
|
+
try { body = JSON.parse(bodyBuf.toString('utf8')) } catch {}
|
|
271
|
+
|
|
272
|
+
const apiKey = String(body.apiKey || '').trim()
|
|
273
|
+
if (!apiKey) {
|
|
274
|
+
return writeJson(res, 400, { ok: false, error: 'API key cannot be empty' })
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const pub = publicConfig(live())
|
|
278
|
+
const targetEnvName = String(body.apiKeyEnv || pub.apiKeyEnv || DEFAULT_API_KEY_ENV).trim()
|
|
279
|
+
await saveCredentialKey(ctx, targetEnvName, apiKey)
|
|
280
|
+
|
|
281
|
+
// Run validation probe with the newly saved key
|
|
282
|
+
const validation = await smokeChat(pub.baseUrl, apiKey, {
|
|
283
|
+
model: pub.defaultModel,
|
|
284
|
+
timeoutMs: 15000,
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
writeJson(res, 200, {
|
|
288
|
+
ok: true,
|
|
289
|
+
envName: targetEnvName,
|
|
290
|
+
validated: validation.ok,
|
|
291
|
+
latencyMs: validation.latencyMs,
|
|
292
|
+
validationError: validation.ok ? null : validation.error,
|
|
293
|
+
})
|
|
294
|
+
} catch (err) {
|
|
295
|
+
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
}), 'dsh-clinebot: /save-key')
|
|
299
|
+
|
|
300
|
+
// 4. GET /dsh-clinebot/usage — direct fresh usage limit query
|
|
301
|
+
ctx.effect(() => ctx.webServer.register({
|
|
302
|
+
kind: 'exact',
|
|
303
|
+
path: '/dsh-clinebot/usage',
|
|
304
|
+
handler: async (req, res) => {
|
|
305
|
+
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
|
|
306
|
+
try {
|
|
307
|
+
const pub = publicConfig(live())
|
|
308
|
+
const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
|
|
309
|
+
if (!key.value) {
|
|
310
|
+
return writeJson(res, 400, { ok: false, error: 'API key not found' })
|
|
311
|
+
}
|
|
312
|
+
const usageData = await fetchUsageLimits(pub.baseUrl, key.value, {
|
|
313
|
+
timeoutMs: pub.timeoutMs,
|
|
314
|
+
bypassCache: true,
|
|
315
|
+
})
|
|
316
|
+
writeJson(res, usageData.ok ? 200 : 502, usageData)
|
|
317
|
+
} catch (err) {
|
|
318
|
+
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
}), 'dsh-clinebot: /usage')
|
|
322
|
+
|
|
323
|
+
// 5. POST /dsh-clinebot/register — upsert into DSH llm-pi-ai
|
|
324
|
+
ctx.effect(() => ctx.webServer.register({
|
|
325
|
+
kind: 'exact',
|
|
326
|
+
path: '/dsh-clinebot/register',
|
|
327
|
+
handler: async (req, res) => {
|
|
328
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
329
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
330
|
+
return writeJson(res, 403, { ok: false, error: 'Forbidden' })
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
const bodyBuf = await readBody(req)
|
|
334
|
+
let body = {}
|
|
335
|
+
try { body = JSON.parse(bodyBuf.toString('utf8')) } catch {}
|
|
336
|
+
const result = await upsertPiAiProvider(ctx, live(), body.models)
|
|
337
|
+
writeJson(res, 200, { ok: true, provider: result })
|
|
338
|
+
} catch (err) {
|
|
339
|
+
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
}), 'dsh-clinebot: /register')
|
|
343
|
+
|
|
344
|
+
// 6. POST /dsh-clinebot/unregister — remove from DSH
|
|
345
|
+
ctx.effect(() => ctx.webServer.register({
|
|
346
|
+
kind: 'exact',
|
|
347
|
+
path: '/dsh-clinebot/unregister',
|
|
348
|
+
handler: async (req, res) => {
|
|
349
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
350
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
351
|
+
return writeJson(res, 403, { ok: false, error: 'Forbidden' })
|
|
352
|
+
}
|
|
353
|
+
try {
|
|
354
|
+
await removePiAiProvider(ctx)
|
|
355
|
+
writeJson(res, 200, { ok: true })
|
|
356
|
+
} catch (err) {
|
|
357
|
+
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
}), 'dsh-clinebot: /unregister')
|
|
361
|
+
|
|
362
|
+
// 7. POST /dsh-clinebot/smoke — live ping test
|
|
363
|
+
ctx.effect(() => ctx.webServer.register({
|
|
364
|
+
kind: 'exact',
|
|
365
|
+
path: '/dsh-clinebot/smoke',
|
|
366
|
+
handler: async (req, res) => {
|
|
367
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
368
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
369
|
+
return writeJson(res, 403, { ok: false, error: 'Forbidden' })
|
|
370
|
+
}
|
|
371
|
+
try {
|
|
372
|
+
const bodyBuf = await readBody(req)
|
|
373
|
+
let body = {}
|
|
374
|
+
try { body = JSON.parse(bodyBuf.toString('utf8')) } catch {}
|
|
375
|
+
|
|
376
|
+
const pub = publicConfig(live())
|
|
377
|
+
const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
|
|
378
|
+
if (!key.value) {
|
|
379
|
+
return writeJson(res, 400, {
|
|
380
|
+
ok: false,
|
|
381
|
+
error: `API key not found. Ensure ${key.envName} is added to DSH credentials or environment.`,
|
|
382
|
+
})
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const modelToTest = body.model || pub.defaultModel || DEFAULT_MODEL_ID
|
|
386
|
+
const outcome = await smokeChat(pub.baseUrl, key.value, {
|
|
387
|
+
model: modelToTest,
|
|
388
|
+
timeoutMs: pub.smokeTimeoutMs,
|
|
389
|
+
})
|
|
390
|
+
writeJson(res, outcome.ok ? 200 : 502, outcome)
|
|
391
|
+
} catch (err) {
|
|
392
|
+
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
393
|
+
}
|
|
394
|
+
},
|
|
395
|
+
}), 'dsh-clinebot: /smoke')
|
|
396
|
+
|
|
397
|
+
// 8. POST & DELETE /dsh-clinebot/models/custom — add/update or remove custom model
|
|
398
|
+
ctx.effect(() => ctx.webServer.register({
|
|
399
|
+
kind: 'exact',
|
|
400
|
+
path: '/dsh-clinebot/models/custom',
|
|
401
|
+
handler: async (req, res) => {
|
|
402
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
403
|
+
return writeJson(res, 403, { ok: false, error: 'Forbidden' })
|
|
404
|
+
}
|
|
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
|
+
}
|
|
423
|
+
|
|
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
|
+
}
|
|
438
|
+
|
|
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) })
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
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 {}
|
|
450
|
+
|
|
451
|
+
const modelId = String(body.id || '').trim()
|
|
452
|
+
if (!modelId) {
|
|
453
|
+
return writeJson(res, 400, { ok: false, error: 'Missing model ID' })
|
|
454
|
+
}
|
|
455
|
+
|
|
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
|
+
}
|
|
469
|
+
}
|
|
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
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return writeJson(res, 405, { ok: false, error: 'POST or DELETE' })
|
|
478
|
+
},
|
|
479
|
+
}), 'dsh-clinebot: /models/custom')
|
|
480
|
+
|
|
481
|
+
// 9. POST /dsh-clinebot/models/toggle — toggle enabled status in picker
|
|
482
|
+
ctx.effect(() => ctx.webServer.register({
|
|
483
|
+
kind: 'exact',
|
|
484
|
+
path: '/dsh-clinebot/models/toggle',
|
|
485
|
+
handler: async (req, res) => {
|
|
486
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
487
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
488
|
+
return writeJson(res, 403, { ok: false, error: 'Forbidden' })
|
|
489
|
+
}
|
|
490
|
+
try {
|
|
491
|
+
const bodyBuf = await readBody(req)
|
|
492
|
+
let body = {}
|
|
493
|
+
try { body = JSON.parse(bodyBuf.toString('utf8')) } catch {}
|
|
494
|
+
|
|
495
|
+
if (Array.isArray(body.enabledModels) && settingsApi?.replace) {
|
|
496
|
+
const patch = { enabledModels: body.enabledModels }
|
|
497
|
+
if (body.defaultModel) patch.defaultModel = body.defaultModel
|
|
498
|
+
const next = Config({ ...live(), ...patch })
|
|
499
|
+
await settingsApi.replace(next)
|
|
500
|
+
if (await checkRegisteredInPiAi(ctx)) {
|
|
501
|
+
await upsertPiAiProvider(ctx, next, body.enabledModels)
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
writeJson(res, 200, { ok: true, enabledModels: body.enabledModels })
|
|
505
|
+
} catch (err) {
|
|
506
|
+
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
507
|
+
}
|
|
508
|
+
},
|
|
509
|
+
}), 'dsh-clinebot: /models/toggle')
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Register /cline chat slash-command if commands service is present
|
|
513
|
+
ctx.inject(['commands'], (cmdCtx) => {
|
|
514
|
+
const commands = cmdCtx.commands
|
|
515
|
+
if (typeof commands?.register !== 'function') return
|
|
516
|
+
|
|
517
|
+
const unregister = commands.register({
|
|
518
|
+
name: 'cline',
|
|
519
|
+
description: 'Check ClinePass subscription quota, rate limits and latency',
|
|
520
|
+
execute: async () => {
|
|
521
|
+
const pub = publicConfig(live())
|
|
522
|
+
const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
|
|
523
|
+
if (!key.value) {
|
|
524
|
+
return '⚠️ **ClineBot**: API-ключ не настроен. Откройте **Настройки → ClineBot** и сохраните ключ.'
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const [health, usage] = await Promise.all([
|
|
528
|
+
probeHealth(pub.baseUrl, { timeoutMs: 5000 }),
|
|
529
|
+
fetchUsageLimits(pub.baseUrl, key.value, { timeoutMs: 8000 }),
|
|
530
|
+
])
|
|
531
|
+
|
|
532
|
+
const fiveHour = usage?.windows?.fiveHour
|
|
533
|
+
const weekly = usage?.windows?.weekly
|
|
534
|
+
const reset5h = fiveHour?.resetsAt ? new Date(fiveHour.resetsAt).toLocaleTimeString() : 'н/д'
|
|
535
|
+
const resetWk = weekly?.resetsAt ? new Date(weekly.resetsAt).toLocaleDateString() : 'н/д'
|
|
536
|
+
|
|
537
|
+
const lines = [
|
|
538
|
+
`### 🤖 ClinePass Status (${usage?.plan || 'ClinePass'})`,
|
|
539
|
+
`* **Пинг хоста**: ${health.ok ? `✅ ${health.latencyMs} мс` : '❌ Недоступен'}`,
|
|
540
|
+
`* **Активный ключ**: ${key.envName} (${key.source})`,
|
|
541
|
+
`* **Модель по умолчанию**: \`${pub.defaultModel}\``,
|
|
542
|
+
'',
|
|
543
|
+
`**⏱ 5-часовое окно**: ${formatProgressBar(fiveHour?.percentUsed)} (сброс: ${reset5h})`,
|
|
544
|
+
`**📅 Недельное окно**: ${formatProgressBar(weekly?.percentUsed)} (сброс: ${resetWk})`,
|
|
545
|
+
]
|
|
546
|
+
|
|
547
|
+
if (usage?.user?.email) {
|
|
548
|
+
lines.push(`* **Аккаунт**: \`${usage.user.email}\``)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return lines.join('\n')
|
|
552
|
+
},
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
ctx.effect(() => () => unregister?.(), 'dsh-clinebot: slash-command')
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
return {
|
|
559
|
+
getStatus: () => buildStatus(ctx, live()),
|
|
560
|
+
registerProvider: (models) => upsertPiAiProvider(ctx, live(), models),
|
|
561
|
+
unregisterProvider: () => removePiAiProvider(ctx),
|
|
562
|
+
getUsageLimits: async () => {
|
|
563
|
+
const pub = publicConfig(live())
|
|
564
|
+
const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
|
|
565
|
+
return fetchUsageLimits(pub.baseUrl, key.value)
|
|
566
|
+
},
|
|
567
|
+
runSmokeTest: async (model) => {
|
|
568
|
+
const pub = publicConfig(live())
|
|
569
|
+
const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
|
|
570
|
+
return smokeChat(pub.baseUrl, key.value, { model: model || pub.defaultModel })
|
|
571
|
+
},
|
|
572
|
+
}
|
|
573
|
+
}
|