@goodandready/dsh-clinebot 0.3.11 → 0.3.12

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.
@@ -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
+ }