@goodandready/dsh-clinebot 0.3.11 → 0.3.13
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 +26 -0
- package/lib/access.js +10 -0
- package/lib/account-pool.js +131 -0
- package/lib/client.js +1137 -1142
- package/lib/cline-client.js +1 -128
- package/lib/config.js +84 -0
- package/lib/index.js +36 -864
- package/lib/provider-sync.js +216 -0
- package/lib/routes/accounts.js +79 -0
- package/lib/routes/auth.js +102 -0
- package/lib/routes/models.js +126 -0
- package/lib/routes/settings.js +102 -0
- package/lib/slash-command.js +198 -0
- package/package.json +3 -2
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import os from 'node:os'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { publicConfig, Config, LLM_PI_AI_NS } from './config.js'
|
|
4
|
+
import {
|
|
5
|
+
PROVIDER_ID,
|
|
6
|
+
PROVIDER_DISPLAY_NAME,
|
|
7
|
+
getAllModels,
|
|
8
|
+
loadModelsDiskCache,
|
|
9
|
+
saveModelsDiskCache,
|
|
10
|
+
} from './models.js'
|
|
11
|
+
import {
|
|
12
|
+
resolveKeyValue,
|
|
13
|
+
resolveAccountPool,
|
|
14
|
+
probeHealth,
|
|
15
|
+
fetchUsageLimits,
|
|
16
|
+
buildPiAiProvider,
|
|
17
|
+
sessionStats,
|
|
18
|
+
} from './cline-client.js'
|
|
19
|
+
|
|
20
|
+
export function resolvePathWithHome(p) {
|
|
21
|
+
if (!p || typeof p !== 'string') return ''
|
|
22
|
+
if (p === '~') return os.homedir()
|
|
23
|
+
if (p.startsWith('~/') || p.startsWith('~\\')) {
|
|
24
|
+
return path.join(os.homedir(), p.slice(2))
|
|
25
|
+
}
|
|
26
|
+
return p
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function checkRegisteredInPiAi(ctx) {
|
|
30
|
+
const settings = ctx?.get?.('settings')
|
|
31
|
+
if (!settings?.get) return false
|
|
32
|
+
try {
|
|
33
|
+
const piAi = settings.get(LLM_PI_AI_NS)
|
|
34
|
+
return !!piAi?.providers?.[PROVIDER_ID]
|
|
35
|
+
} catch {
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function resolveActiveAccountKey(ctx, cfg) {
|
|
41
|
+
const pool = await resolveAccountPool(ctx, cfg)
|
|
42
|
+
const configured = pool.filter((acc) => acc.present && acc.value)
|
|
43
|
+
if (!configured.length) {
|
|
44
|
+
return { envName: publicConfig(cfg).apiKeyEnv, value: '', source: 'none', id: 'default' }
|
|
45
|
+
}
|
|
46
|
+
const pub = publicConfig(cfg)
|
|
47
|
+
if (pub.activeAccount) {
|
|
48
|
+
const pinned = configured.find((acc) => acc.apiKeyEnv === pub.activeAccount || acc.id === pub.activeAccount)
|
|
49
|
+
if (pinned) return pinned
|
|
50
|
+
}
|
|
51
|
+
return configured[0]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function buildStatus(ctx, cfg) {
|
|
55
|
+
const pub = publicConfig(cfg)
|
|
56
|
+
const probeTimeout = Math.min(2500, pub.timeoutMs || 2500)
|
|
57
|
+
|
|
58
|
+
const [key, pool, isRegistered, health] = await Promise.all([
|
|
59
|
+
resolveKeyValue(ctx, pub.apiKeyEnv),
|
|
60
|
+
resolveAccountPool(ctx, cfg),
|
|
61
|
+
checkRegisteredInPiAi(ctx),
|
|
62
|
+
probeHealth(pub.baseUrl, { timeoutMs: probeTimeout }),
|
|
63
|
+
])
|
|
64
|
+
|
|
65
|
+
const activeAcc = await resolveActiveAccountKey(ctx, cfg)
|
|
66
|
+
const allModels = getAllModels(pub.dynamicModels)
|
|
67
|
+
|
|
68
|
+
let usage = null
|
|
69
|
+
const keyToUse = activeAcc.value || key.value
|
|
70
|
+
if (keyToUse) {
|
|
71
|
+
usage = await fetchUsageLimits(pub.baseUrl, keyToUse, { timeoutMs: probeTimeout }).catch(() => null)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let quotaWarning = null
|
|
75
|
+
if (usage?.windows?.fiveHour) {
|
|
76
|
+
const pct = usage.windows.fiveHour.percentUsed
|
|
77
|
+
if (pct >= 95) {
|
|
78
|
+
quotaWarning = {
|
|
79
|
+
level: 'exhausted',
|
|
80
|
+
message: `5-hour rolling limit is almost exhausted (${pct}%). New requests may be rejected until quota reset.`,
|
|
81
|
+
resetsAt: usage.windows.fiveHour.resetsAt,
|
|
82
|
+
}
|
|
83
|
+
} else if (pct >= 80) {
|
|
84
|
+
quotaWarning = {
|
|
85
|
+
level: 'warning',
|
|
86
|
+
message: `Notice: ${pct}% of the 5-hour rolling limit has been consumed.`,
|
|
87
|
+
resetsAt: usage.windows.fiveHour.resetsAt,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
ok: true,
|
|
94
|
+
providerId: PROVIDER_ID,
|
|
95
|
+
displayName: PROVIDER_DISPLAY_NAME,
|
|
96
|
+
config: pub,
|
|
97
|
+
key: {
|
|
98
|
+
envName: activeAcc.apiKeyEnv || key.envName,
|
|
99
|
+
present: !!(activeAcc.value || key.value),
|
|
100
|
+
source: activeAcc.source || key.source,
|
|
101
|
+
},
|
|
102
|
+
accounts: pool.map((acc) => ({
|
|
103
|
+
id: acc.id,
|
|
104
|
+
label: acc.label,
|
|
105
|
+
apiKeyEnv: acc.apiKeyEnv,
|
|
106
|
+
present: acc.present,
|
|
107
|
+
source: acc.source,
|
|
108
|
+
isPinned: acc.isPinned,
|
|
109
|
+
})),
|
|
110
|
+
activeAccount: activeAcc.apiKeyEnv,
|
|
111
|
+
health,
|
|
112
|
+
usage,
|
|
113
|
+
quotaWarning,
|
|
114
|
+
sessionStats: { ...sessionStats },
|
|
115
|
+
isRegistered,
|
|
116
|
+
availableModels: allModels,
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function upsertPiAiProvider(ctx, cfg, activeModelIds) {
|
|
121
|
+
const settings = ctx?.get?.('settings')
|
|
122
|
+
if (!settings?.mutate) {
|
|
123
|
+
throw new Error('DSH settings service unavailable')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const pub = publicConfig(cfg)
|
|
127
|
+
const allModels = getAllModels(pub.dynamicModels)
|
|
128
|
+
const allowedSet = new Set(activeModelIds || pub.enabledModels)
|
|
129
|
+
const modelsToRegister = allModels.filter((m) => allowedSet.has(m.id))
|
|
130
|
+
|
|
131
|
+
const providerObj = buildPiAiProvider({
|
|
132
|
+
baseUrl: pub.baseUrl,
|
|
133
|
+
apiKeyEnv: pub.apiKeyEnv,
|
|
134
|
+
models: modelsToRegister.length ? modelsToRegister : allModels,
|
|
135
|
+
customModels: pub.dynamicModels,
|
|
136
|
+
displayName: PROVIDER_DISPLAY_NAME,
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
await settings.mutate(LLM_PI_AI_NS, [
|
|
140
|
+
{
|
|
141
|
+
op: 'set',
|
|
142
|
+
path: ['providers', PROVIDER_ID],
|
|
143
|
+
value: providerObj,
|
|
144
|
+
},
|
|
145
|
+
])
|
|
146
|
+
|
|
147
|
+
return providerObj
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function removePiAiProvider(ctx) {
|
|
151
|
+
const settings = ctx?.get?.('settings')
|
|
152
|
+
if (!settings?.mutate) {
|
|
153
|
+
throw new Error('DSH settings service unavailable')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
await settings.mutate(LLM_PI_AI_NS, [
|
|
157
|
+
{
|
|
158
|
+
op: 'remove',
|
|
159
|
+
path: ['providers', PROVIDER_ID],
|
|
160
|
+
},
|
|
161
|
+
])
|
|
162
|
+
return { ok: true }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function formatProgressBar(pct, totalWidth = 10) {
|
|
166
|
+
const clamped = Math.max(0, Math.min(100, pct || 0))
|
|
167
|
+
const filled = Math.round((clamped / 100) * totalWidth)
|
|
168
|
+
const empty = Math.max(0, totalWidth - filled)
|
|
169
|
+
return `[${'█'.repeat(filled)}${'░'.repeat(empty)}] ${clamped}%`
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function autoDiscoverPlanModels(ctx, { live, getSettingsApi, syncProviderState }) {
|
|
173
|
+
try {
|
|
174
|
+
const cfg = live()
|
|
175
|
+
const pub = publicConfig(cfg)
|
|
176
|
+
const cacheFile = resolvePathWithHome(pub.modelsCachePath)
|
|
177
|
+
const settingsApi = getSettingsApi()
|
|
178
|
+
|
|
179
|
+
if ((!pub.dynamicModels || !pub.dynamicModels.length) && cacheFile) {
|
|
180
|
+
const fromDisk = await loadModelsDiskCache(cacheFile)
|
|
181
|
+
if (Array.isArray(fromDisk) && fromDisk.length && settingsApi?.replace) {
|
|
182
|
+
const next = Config({ ...live(), dynamicModels: fromDisk })
|
|
183
|
+
await settingsApi.replace(next)
|
|
184
|
+
await syncProviderState(next)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const activeAcc = await resolveActiveAccountKey(ctx, cfg)
|
|
189
|
+
if (!activeAcc.value) return
|
|
190
|
+
|
|
191
|
+
const usageData = await fetchUsageLimits(pub.baseUrl, activeAcc.value, {
|
|
192
|
+
timeoutMs: Math.min(pub.timeoutMs, 5000),
|
|
193
|
+
bypassCache: true,
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
if (usageData?.ok && Array.isArray(usageData.dynamicModels) && usageData.dynamicModels.length > 0) {
|
|
197
|
+
const existingDynamic = pub.dynamicModels || []
|
|
198
|
+
const existingIds = new Set(existingDynamic.map((m) => m.id))
|
|
199
|
+
const hasNew = usageData.dynamicModels.some((m) => !existingIds.has(m.id))
|
|
200
|
+
|
|
201
|
+
if (hasNew && settingsApi?.replace) {
|
|
202
|
+
const next = Config({
|
|
203
|
+
...live(),
|
|
204
|
+
dynamicModels: usageData.dynamicModels,
|
|
205
|
+
})
|
|
206
|
+
await settingsApi.replace(next)
|
|
207
|
+
await syncProviderState(next)
|
|
208
|
+
if (cacheFile) {
|
|
209
|
+
await saveModelsDiskCache(cacheFile, usageData.dynamicModels)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
/* best-effort discovery */
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { writeJson, readBody } from '../http.js'
|
|
2
|
+
import { isTrustedSettingsRequest } from '../access.js'
|
|
3
|
+
import { publicConfig, Config } from '../config.js'
|
|
4
|
+
import { upsertPiAiProvider, removePiAiProvider } from '../provider-sync.js'
|
|
5
|
+
import { clearUsageCache, clearProbeCache } from '../cline-client.js'
|
|
6
|
+
|
|
7
|
+
export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProviderState }) {
|
|
8
|
+
// POST /dsh-clinebot/accounts/active — switch or pin active account
|
|
9
|
+
ctx.effect(() => ctx.webServer.register({
|
|
10
|
+
kind: 'exact',
|
|
11
|
+
path: '/dsh-clinebot/accounts/active',
|
|
12
|
+
handler: async (req, res) => {
|
|
13
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
14
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
15
|
+
return writeJson(res, 403, { ok: false, error: 'Forbidden' })
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const bodyBuf = await readBody(req)
|
|
19
|
+
let body = {}
|
|
20
|
+
try { body = JSON.parse(bodyBuf.toString('utf8')) } catch {}
|
|
21
|
+
const account = String(body.account || '').trim()
|
|
22
|
+
|
|
23
|
+
clearUsageCache()
|
|
24
|
+
clearProbeCache()
|
|
25
|
+
const settingsApi = getSettingsApi()
|
|
26
|
+
if (settingsApi?.replace) {
|
|
27
|
+
const next = Config({ ...live(), activeAccount: account })
|
|
28
|
+
await settingsApi.replace(next)
|
|
29
|
+
await syncProviderState(next)
|
|
30
|
+
writeJson(res, 200, { ok: true, activeAccount: next.activeAccount })
|
|
31
|
+
} else {
|
|
32
|
+
writeJson(res, 200, { ok: true, activeAccount: account })
|
|
33
|
+
}
|
|
34
|
+
} catch (err) {
|
|
35
|
+
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
}), 'dsh-clinebot: /accounts/active')
|
|
39
|
+
|
|
40
|
+
// POST /dsh-clinebot/register — upsert into DSH llm-pi-ai
|
|
41
|
+
ctx.effect(() => ctx.webServer.register({
|
|
42
|
+
kind: 'exact',
|
|
43
|
+
path: '/dsh-clinebot/register',
|
|
44
|
+
handler: async (req, res) => {
|
|
45
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
46
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
47
|
+
return writeJson(res, 403, { ok: false, error: 'Forbidden' })
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const bodyBuf = await readBody(req)
|
|
51
|
+
let body = {}
|
|
52
|
+
try { body = JSON.parse(bodyBuf.toString('utf8')) } catch {}
|
|
53
|
+
const activeModels = body.models || publicConfig(live()).enabledModels
|
|
54
|
+
const result = await upsertPiAiProvider(ctx, live(), activeModels)
|
|
55
|
+
writeJson(res, 200, { ok: true, provider: result })
|
|
56
|
+
} catch (err) {
|
|
57
|
+
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
}), 'dsh-clinebot: /register')
|
|
61
|
+
|
|
62
|
+
// POST /dsh-clinebot/unregister — remove from DSH
|
|
63
|
+
ctx.effect(() => ctx.webServer.register({
|
|
64
|
+
kind: 'exact',
|
|
65
|
+
path: '/dsh-clinebot/unregister',
|
|
66
|
+
handler: async (req, res) => {
|
|
67
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
68
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
69
|
+
return writeJson(res, 403, { ok: false, error: 'Forbidden' })
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
await removePiAiProvider(ctx)
|
|
73
|
+
writeJson(res, 200, { ok: true })
|
|
74
|
+
} catch (err) {
|
|
75
|
+
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
}), 'dsh-clinebot: /unregister')
|
|
79
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { writeJson, readBody } from '../http.js'
|
|
2
|
+
import { isTrustedSettingsRequest } from '../access.js'
|
|
3
|
+
import { publicConfig } from '../config.js'
|
|
4
|
+
import { resolveActiveAccountKey } from '../provider-sync.js'
|
|
5
|
+
import { smokeChat, recordSessionRequest, rotateToNextAccount, DEFAULT_MODEL_ID } from '../cline-client.js'
|
|
6
|
+
|
|
7
|
+
export function registerAuthRoutes(ctx, { live, getSettingsApi, syncProviderState }) {
|
|
8
|
+
let authSession = null
|
|
9
|
+
|
|
10
|
+
// POST /dsh-clinebot/auth/begin — start loopback auth listener
|
|
11
|
+
ctx.effect(() => ctx.webServer.register({
|
|
12
|
+
kind: 'exact',
|
|
13
|
+
path: '/dsh-clinebot/auth/begin',
|
|
14
|
+
handler: async (req, res) => {
|
|
15
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
16
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
17
|
+
return writeJson(res, 403, { ok: false, error: 'Forbidden' })
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
const authUrl = 'https://app.cline.bot'
|
|
21
|
+
authSession = {
|
|
22
|
+
state: 'waiting',
|
|
23
|
+
startedAt: Date.now(),
|
|
24
|
+
authUrl,
|
|
25
|
+
}
|
|
26
|
+
writeJson(res, 200, {
|
|
27
|
+
ok: true,
|
|
28
|
+
status: authSession.state,
|
|
29
|
+
authUrl,
|
|
30
|
+
})
|
|
31
|
+
} catch (err) {
|
|
32
|
+
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
}), 'dsh-clinebot: /auth/begin')
|
|
36
|
+
|
|
37
|
+
// GET /dsh-clinebot/auth/status — query current fast auth state
|
|
38
|
+
ctx.effect(() => ctx.webServer.register({
|
|
39
|
+
kind: 'exact',
|
|
40
|
+
path: '/dsh-clinebot/auth/status',
|
|
41
|
+
handler: async (req, res) => {
|
|
42
|
+
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
|
|
43
|
+
writeJson(res, 200, {
|
|
44
|
+
ok: true,
|
|
45
|
+
status: authSession?.state || 'idle',
|
|
46
|
+
authUrl: authSession?.authUrl || 'https://app.cline.bot',
|
|
47
|
+
})
|
|
48
|
+
},
|
|
49
|
+
}), 'dsh-clinebot: /auth/status')
|
|
50
|
+
|
|
51
|
+
// POST /dsh-clinebot/smoke — live ping test
|
|
52
|
+
ctx.effect(() => ctx.webServer.register({
|
|
53
|
+
kind: 'exact',
|
|
54
|
+
path: '/dsh-clinebot/smoke',
|
|
55
|
+
handler: async (req, res) => {
|
|
56
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
57
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
58
|
+
return writeJson(res, 403, { ok: false, error: 'Forbidden' })
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const bodyBuf = await readBody(req)
|
|
62
|
+
let body = {}
|
|
63
|
+
try { body = JSON.parse(bodyBuf.toString('utf8')) } catch {}
|
|
64
|
+
|
|
65
|
+
const pub = publicConfig(live())
|
|
66
|
+
const activeKey = await resolveActiveAccountKey(ctx, live())
|
|
67
|
+
if (!activeKey.value) {
|
|
68
|
+
return writeJson(res, 400, {
|
|
69
|
+
ok: false,
|
|
70
|
+
error: `API key not found. Ensure ${activeKey.envName} is added to DSH credentials or environment.`,
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const modelToTest = body.model || pub.defaultModel || DEFAULT_MODEL_ID
|
|
75
|
+
const outcome = await smokeChat(pub.baseUrl, activeKey.value, {
|
|
76
|
+
model: modelToTest,
|
|
77
|
+
timeoutMs: pub.smokeTimeoutMs,
|
|
78
|
+
})
|
|
79
|
+
recordSessionRequest({
|
|
80
|
+
latencyMs: outcome.latencyMs,
|
|
81
|
+
ok: outcome.ok,
|
|
82
|
+
error: outcome.error,
|
|
83
|
+
promptTokens: outcome.promptTokens || 5,
|
|
84
|
+
completionTokens: outcome.completionTokens || 10,
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
let failover = null
|
|
88
|
+
if (outcome.status === 429) {
|
|
89
|
+
failover = await rotateToNextAccount(ctx, live(), 'smoke_429', getSettingsApi())
|
|
90
|
+
if (failover.rotated) {
|
|
91
|
+
await syncProviderState(live())
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
writeJson(res, outcome.ok ? 200 : 502, { ...outcome, failover })
|
|
96
|
+
} catch (err) {
|
|
97
|
+
recordSessionRequest({ ok: false, error: String(err?.message || err) })
|
|
98
|
+
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
}), 'dsh-clinebot: /smoke')
|
|
102
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { writeJson, readBody } from '../http.js'
|
|
2
|
+
import { isTrustedSettingsRequest } from '../access.js'
|
|
3
|
+
import { publicConfig, Config } from '../config.js'
|
|
4
|
+
import { resolveActiveAccountKey, resolvePathWithHome } from '../provider-sync.js'
|
|
5
|
+
import { getAllModels, saveModelsDiskCache } from '../models.js'
|
|
6
|
+
import { fetchUsageLimits } from '../cline-client.js'
|
|
7
|
+
|
|
8
|
+
export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderState }) {
|
|
9
|
+
// POST /dsh-clinebot/models/sync — dynamically load models from plan
|
|
10
|
+
ctx.effect(() => ctx.webServer.register({
|
|
11
|
+
kind: 'exact',
|
|
12
|
+
path: '/dsh-clinebot/models/sync',
|
|
13
|
+
handler: async (req, res) => {
|
|
14
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
15
|
+
return writeJson(res, 403, { ok: false, error: 'Forbidden' })
|
|
16
|
+
}
|
|
17
|
+
if (req.method !== 'POST') {
|
|
18
|
+
return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
const pub = publicConfig(live())
|
|
22
|
+
const activeKey = await resolveActiveAccountKey(ctx, live())
|
|
23
|
+
if (!activeKey.value) {
|
|
24
|
+
return writeJson(res, 400, { ok: false, error: 'API key not configured' })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const usageData = await fetchUsageLimits(pub.baseUrl, activeKey.value, {
|
|
28
|
+
timeoutMs: pub.timeoutMs,
|
|
29
|
+
bypassCache: true,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
if (!usageData.ok) {
|
|
33
|
+
return writeJson(res, 502, { ok: false, error: usageData.error || 'Failed to fetch plan models' })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const dynamicModels = Array.isArray(usageData.dynamicModels) ? usageData.dynamicModels : []
|
|
37
|
+
const allModels = getAllModels(dynamicModels)
|
|
38
|
+
const settingsApi = getSettingsApi()
|
|
39
|
+
|
|
40
|
+
if (settingsApi?.replace) {
|
|
41
|
+
const next = Config({
|
|
42
|
+
...live(),
|
|
43
|
+
dynamicModels,
|
|
44
|
+
})
|
|
45
|
+
await settingsApi.replace(next)
|
|
46
|
+
await syncProviderState(next)
|
|
47
|
+
const cacheFile = resolvePathWithHome(pub.modelsCachePath)
|
|
48
|
+
if (cacheFile) {
|
|
49
|
+
await saveModelsDiskCache(cacheFile, dynamicModels)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return writeJson(res, 200, {
|
|
54
|
+
ok: true,
|
|
55
|
+
plan: usageData.plan,
|
|
56
|
+
discoveredCount: dynamicModels.length,
|
|
57
|
+
totalModelsCount: allModels.length,
|
|
58
|
+
models: allModels,
|
|
59
|
+
})
|
|
60
|
+
} catch (err) {
|
|
61
|
+
return writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
}), 'dsh-clinebot: /models/sync')
|
|
65
|
+
|
|
66
|
+
// POST /dsh-clinebot/models/toggle — toggle disabled/enabled status in picker
|
|
67
|
+
ctx.effect(() => ctx.webServer.register({
|
|
68
|
+
kind: 'exact',
|
|
69
|
+
path: '/dsh-clinebot/models/toggle',
|
|
70
|
+
handler: async (req, res) => {
|
|
71
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
72
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
73
|
+
return writeJson(res, 403, { ok: false, error: 'Forbidden' })
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const bodyBuf = await readBody(req)
|
|
77
|
+
let body = {}
|
|
78
|
+
try { body = JSON.parse(bodyBuf.toString('utf8')) } catch {}
|
|
79
|
+
|
|
80
|
+
const settingsApi = getSettingsApi()
|
|
81
|
+
if (settingsApi?.replace) {
|
|
82
|
+
const patch = {}
|
|
83
|
+
if (Array.isArray(body.disabledModels)) {
|
|
84
|
+
patch.disabledModels = body.disabledModels
|
|
85
|
+
} else if (Array.isArray(body.enabledModels)) {
|
|
86
|
+
const allModels = getAllModels(live().dynamicModels)
|
|
87
|
+
const enabledSet = new Set(body.enabledModels)
|
|
88
|
+
patch.disabledModels = allModels.map((m) => m.id).filter((id) => !enabledSet.has(id))
|
|
89
|
+
}
|
|
90
|
+
if (body.defaultModel) patch.defaultModel = body.defaultModel
|
|
91
|
+
const next = Config({ ...live(), ...patch })
|
|
92
|
+
await settingsApi.replace(next)
|
|
93
|
+
await syncProviderState(next)
|
|
94
|
+
writeJson(res, 200, { ok: true, disabledModels: next.disabledModels, enabledModels: publicConfig(next).enabledModels })
|
|
95
|
+
} else {
|
|
96
|
+
writeJson(res, 200, { ok: true })
|
|
97
|
+
}
|
|
98
|
+
} catch (err) {
|
|
99
|
+
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
}), 'dsh-clinebot: /models/toggle')
|
|
103
|
+
|
|
104
|
+
// GET /dsh-clinebot/usage — direct fresh usage limit query
|
|
105
|
+
ctx.effect(() => ctx.webServer.register({
|
|
106
|
+
kind: 'exact',
|
|
107
|
+
path: '/dsh-clinebot/usage',
|
|
108
|
+
handler: async (req, res) => {
|
|
109
|
+
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
|
|
110
|
+
try {
|
|
111
|
+
const pub = publicConfig(live())
|
|
112
|
+
const activeKey = await resolveActiveAccountKey(ctx, live())
|
|
113
|
+
if (!activeKey.value) {
|
|
114
|
+
return writeJson(res, 400, { ok: false, error: 'API key not found' })
|
|
115
|
+
}
|
|
116
|
+
const usageData = await fetchUsageLimits(pub.baseUrl, activeKey.value, {
|
|
117
|
+
timeoutMs: pub.timeoutMs,
|
|
118
|
+
bypassCache: true,
|
|
119
|
+
})
|
|
120
|
+
writeJson(res, usageData.ok ? 200 : 502, usageData)
|
|
121
|
+
} catch (err) {
|
|
122
|
+
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
}), 'dsh-clinebot: /usage')
|
|
126
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { writeJson, readBody } from '../http.js'
|
|
2
|
+
import { isTrustedSettingsRequest } from '../access.js'
|
|
3
|
+
import { publicConfig, Config } from '../config.js'
|
|
4
|
+
import { buildStatus } from '../provider-sync.js'
|
|
5
|
+
import { saveCredentialKey, smokeChat, DEFAULT_API_KEY_ENV } from '../cline-client.js'
|
|
6
|
+
|
|
7
|
+
export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProviderState, triggerAutoDiscover }) {
|
|
8
|
+
// 1. GET /dsh-clinebot/status
|
|
9
|
+
ctx.effect(() => ctx.webServer.register({
|
|
10
|
+
kind: 'exact',
|
|
11
|
+
path: '/dsh-clinebot/status',
|
|
12
|
+
handler: async (req, res) => {
|
|
13
|
+
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
|
|
14
|
+
try {
|
|
15
|
+
const st = await buildStatus(ctx, live())
|
|
16
|
+
writeJson(res, 200, st)
|
|
17
|
+
} catch (err) {
|
|
18
|
+
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
}), 'dsh-clinebot: /status')
|
|
22
|
+
|
|
23
|
+
// 2. GET & PUT /dsh-clinebot/config
|
|
24
|
+
ctx.effect(() => ctx.webServer.register({
|
|
25
|
+
kind: 'exact',
|
|
26
|
+
path: '/dsh-clinebot/config',
|
|
27
|
+
handler: async (req, res) => {
|
|
28
|
+
if (req.method === 'GET') {
|
|
29
|
+
return writeJson(res, 200, { ok: true, config: publicConfig(live()) })
|
|
30
|
+
}
|
|
31
|
+
if (req.method !== 'PUT') {
|
|
32
|
+
return writeJson(res, 405, { ok: false, error: 'GET or PUT' })
|
|
33
|
+
}
|
|
34
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
35
|
+
return writeJson(res, 403, { ok: false, error: 'same-origin only' })
|
|
36
|
+
}
|
|
37
|
+
const settingsApi = getSettingsApi()
|
|
38
|
+
if (!settingsApi) {
|
|
39
|
+
return writeJson(res, 503, { ok: false, error: 'settings not ready' })
|
|
40
|
+
}
|
|
41
|
+
let payload
|
|
42
|
+
try {
|
|
43
|
+
payload = JSON.parse((await readBody(req)).toString('utf8') || '{}')
|
|
44
|
+
} catch {
|
|
45
|
+
return writeJson(res, 400, { ok: false, error: 'invalid json' })
|
|
46
|
+
}
|
|
47
|
+
if (payload && typeof payload.config === 'object') payload = payload.config
|
|
48
|
+
try {
|
|
49
|
+
const parsed = Config({ ...publicConfig(live()), ...payload })
|
|
50
|
+
await settingsApi.replace(parsed)
|
|
51
|
+
await syncProviderState(parsed)
|
|
52
|
+
writeJson(res, 200, { ok: true, config: publicConfig(live()) })
|
|
53
|
+
} catch (e) {
|
|
54
|
+
writeJson(res, 400, { ok: false, error: String(e?.message || e) })
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
}), 'dsh-clinebot: /config')
|
|
58
|
+
|
|
59
|
+
// 3. POST /dsh-clinebot/save-key — direct saving into DSH credentials service
|
|
60
|
+
ctx.effect(() => ctx.webServer.register({
|
|
61
|
+
kind: 'exact',
|
|
62
|
+
path: '/dsh-clinebot/save-key',
|
|
63
|
+
handler: async (req, res) => {
|
|
64
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
65
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
66
|
+
return writeJson(res, 403, { ok: false, error: 'Forbidden' })
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const bodyBuf = await readBody(req)
|
|
70
|
+
let body = {}
|
|
71
|
+
try { body = JSON.parse(bodyBuf.toString('utf8')) } catch {}
|
|
72
|
+
|
|
73
|
+
const apiKey = String(body.apiKey || '').trim()
|
|
74
|
+
if (!apiKey) {
|
|
75
|
+
return writeJson(res, 400, { ok: false, error: 'API key cannot be empty' })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const pub = publicConfig(live())
|
|
79
|
+
const targetEnvName = String(body.apiKeyEnv || pub.apiKeyEnv || DEFAULT_API_KEY_ENV).trim()
|
|
80
|
+
await saveCredentialKey(ctx, targetEnvName, apiKey)
|
|
81
|
+
|
|
82
|
+
await syncProviderState(live())
|
|
83
|
+
triggerAutoDiscover()
|
|
84
|
+
|
|
85
|
+
const validation = await smokeChat(pub.baseUrl, apiKey, {
|
|
86
|
+
model: pub.defaultModel,
|
|
87
|
+
timeoutMs: 15000,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
writeJson(res, 200, {
|
|
91
|
+
ok: true,
|
|
92
|
+
envName: targetEnvName,
|
|
93
|
+
validated: validation.ok,
|
|
94
|
+
latencyMs: validation.latencyMs,
|
|
95
|
+
validationError: validation.ok ? null : validation.error,
|
|
96
|
+
})
|
|
97
|
+
} catch (err) {
|
|
98
|
+
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
}), 'dsh-clinebot: /save-key')
|
|
102
|
+
}
|