@goodandready/dsh-clinebot 0.4.7 → 0.5.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.
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Transparent Loopback Proxy & Zero-Downtime Failover (Issue #127)
3
+ *
4
+ * Exposes local OpenAI-compatible endpoints:
5
+ * - POST /dsh-clinebot/v1/chat/completions
6
+ * - GET /dsh-clinebot/v1/models
7
+ *
8
+ * Implements:
9
+ * 1. Sticky session routing with least-used quota allocation.
10
+ * 2. Transparent upstream retry on 429 / quota exhaustion before first chunk is streamed.
11
+ * 3. Accurate streaming SSE token usage interception & persistent telemetry recording.
12
+ * 4. Model reasoning_effort default injection from config.
13
+ */
14
+
15
+ import { writeJson, readBody, isLoopbackAddress } from '../http.js'
16
+ import { publicConfig, plainConfig } from '../config.js'
17
+ import { resolveAccountPool } from '../account-pool.js'
18
+ import { resolveSessionAccount, markAccountCooldown } from '../session-router.js'
19
+ import { recordUsage } from '../stats-storage.js'
20
+ import { getAllModels, DEFAULT_MODEL_ID } from '../models.js'
21
+
22
+ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderState }) {
23
+ // 1. POST /dsh-clinebot/v1/chat/completions
24
+ ctx.effect(() => ctx.webServer.register({
25
+ kind: 'exact',
26
+ path: '/dsh-clinebot/v1/chat/completions',
27
+ handler: async (req, res) => {
28
+ if (req.method !== 'POST') {
29
+ return writeJson(res, 405, { ok: false, error: 'POST only' })
30
+ }
31
+
32
+ if (!isLoopbackAddress(req.socket?.remoteAddress)) {
33
+ return writeJson(res, 403, { ok: false, error: 'Loopback access only' })
34
+ }
35
+
36
+ let bodyBuf
37
+ try {
38
+ bodyBuf = await readBody(req)
39
+ } catch (err) {
40
+ return writeJson(res, 400, { ok: false, error: String(err?.message || err) })
41
+ }
42
+
43
+ let body
44
+ try {
45
+ body = JSON.parse(bodyBuf.toString('utf8') || '{}')
46
+ } catch {
47
+ return writeJson(res, 400, { ok: false, error: 'Invalid JSON request body' })
48
+ }
49
+
50
+ const cfg = plainConfig(live())
51
+ const pub = publicConfig(cfg)
52
+
53
+ // Session identification
54
+ const sessionId = String(
55
+ req.headers['x-dsh-session-id'] ||
56
+ req.headers['x-session-id'] ||
57
+ body?.conversation_id ||
58
+ body?.chat_id ||
59
+ body?.user ||
60
+ ''
61
+ ).trim()
62
+
63
+ // Ensure model is set, fallback to defaultModel (#140)
64
+ body.model = String(body.model || pub.defaultModel || DEFAULT_MODEL_ID).trim()
65
+
66
+ // Inject model reasoning effort if model has default configured and payload omitted it
67
+ if (
68
+ pub.modelReasoningDefaults &&
69
+ pub.modelReasoningDefaults[body.model] &&
70
+ !body.reasoning_effort
71
+ ) {
72
+ body.reasoning_effort = pub.modelReasoningDefaults[body.model]
73
+ }
74
+
75
+ const pool = await resolveAccountPool(ctx, cfg)
76
+ const isStreaming = Boolean(body.stream) || String(req.headers.accept || '').includes('text/event-stream')
77
+
78
+ // Transparent retry loop (up to max configured accounts, min 1, max 3)
79
+ const maxRetries = Math.min(Math.max(1, pool.length), 3)
80
+ let attempt = 0
81
+ let lastError = null
82
+ let targetAccount = null
83
+
84
+ while (attempt < maxRetries) {
85
+ attempt++
86
+ const routeResult = resolveSessionAccount(sessionId, pool, pub.activeAccount)
87
+ targetAccount = routeResult.account
88
+
89
+ if (!targetAccount || !targetAccount.value) {
90
+ return writeJson(res, 503, {
91
+ error: {
92
+ message: 'No available ClinePass API keys configured in pool',
93
+ type: 'service_unavailable',
94
+ code: 'no_keys_available'
95
+ }
96
+ })
97
+ }
98
+
99
+ const upstreamUrl = `${pub.baseUrl}/chat/completions`
100
+ const requestHeaders = {
101
+ 'Content-Type': 'application/json',
102
+ 'Authorization': `Bearer ${targetAccount.value}`,
103
+ 'Accept': isStreaming ? 'text/event-stream' : 'application/json',
104
+ 'User-Agent': '@goodandready/dsh-clinebot/0.5.0'
105
+ }
106
+
107
+ let upstreamRes
108
+ try {
109
+ upstreamRes = await fetch(upstreamUrl, {
110
+ method: 'POST',
111
+ headers: requestHeaders,
112
+ body: JSON.stringify(body),
113
+ keepalive: true
114
+ })
115
+ } catch (fetchErr) {
116
+ lastError = fetchErr
117
+ // Network errors: retry with next account if available
118
+ continue
119
+ }
120
+
121
+ // If upstream rejected with 429 or 403 quota exhaustion before streaming, failover!
122
+ if (upstreamRes.status === 429 || upstreamRes.status === 402) {
123
+ const accountId = targetAccount.id || targetAccount.label
124
+ markAccountCooldown(accountId, 60000, null, 'upstream_429')
125
+ recordUsage({
126
+ model: body.model,
127
+ accountId,
128
+ is429: true,
129
+ isError: true,
130
+ statsPath: pub.statsPath
131
+ })
132
+ ctx?.logger?.warn?.(`[dsh-clinebot:proxy] Account "${accountId}" hit 429, attempting failover (attempt ${attempt}/${maxRetries})`)
133
+ continue
134
+ }
135
+
136
+ // Upstream responded with non-retryable response: stream or write JSON to client
137
+ if (isStreaming && upstreamRes.status === 200 && upstreamRes.body) {
138
+ res.statusCode = 200
139
+ res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
140
+ res.setHeader('Cache-Control', 'no-cache, no-transform')
141
+ res.setHeader('Connection', 'keep-alive')
142
+ if (typeof res.flushHeaders === 'function') {
143
+ res.flushHeaders()
144
+ }
145
+
146
+ let promptTokens = 0
147
+ let completionTokens = 0
148
+ let totalTokens = 0
149
+ let buffer = ''
150
+
151
+ const reader = upstreamRes.body.getReader()
152
+ const decoder = new TextDecoder('utf8')
153
+
154
+ let streamAborted = false
155
+ const onClientAbort = () => {
156
+ streamAborted = true
157
+ try {
158
+ reader.cancel().catch(() => {})
159
+ } catch {
160
+ /* ignore */
161
+ }
162
+ }
163
+ if (typeof req.on === 'function') {
164
+ req.on('close', onClientAbort)
165
+ }
166
+
167
+ try {
168
+ while (true) {
169
+ const { done, value } = await reader.read()
170
+ if (done || streamAborted) break
171
+
172
+ const chunkStr = decoder.decode(value, { stream: true })
173
+ res.write(value)
174
+
175
+ // Parse SSE lines for usage metrics
176
+ buffer += chunkStr
177
+ const lines = buffer.split('\n')
178
+ buffer = lines.pop() // keep incomplete last line
179
+
180
+ for (const line of lines) {
181
+ const trimmed = line.trim()
182
+ if (trimmed.startsWith('data: ') && trimmed !== 'data: [DONE]') {
183
+ try {
184
+ const data = JSON.parse(trimmed.slice(6))
185
+ if (data?.usage) {
186
+ promptTokens = Number(data.usage.prompt_tokens) || promptTokens
187
+ completionTokens = Number(data.usage.completion_tokens) || completionTokens
188
+ totalTokens = Number(data.usage.total_tokens) || totalTokens
189
+ }
190
+ } catch {
191
+ /* ignore partial or non-json SSE lines */
192
+ }
193
+ }
194
+ }
195
+ }
196
+ } catch (streamErr) {
197
+ if (!streamAborted) {
198
+ ctx?.logger?.warn?.(`[dsh-clinebot:proxy] Error streaming SSE chunk: ${streamErr?.message || streamErr}`)
199
+ }
200
+ } finally {
201
+ if (typeof req.off === 'function') {
202
+ req.off('close', onClientAbort)
203
+ }
204
+ res.end()
205
+ recordUsage({
206
+ model: body.model,
207
+ accountId: targetAccount.id || targetAccount.label,
208
+ promptTokens,
209
+ completionTokens,
210
+ totalTokens,
211
+ statsPath: pub.statsPath
212
+ })
213
+ }
214
+ return
215
+ }
216
+
217
+ // Non-streaming JSON response or error status from upstream
218
+ const resText = await upstreamRes.text()
219
+ let resJson = null
220
+ try {
221
+ resJson = JSON.parse(resText)
222
+ } catch {
223
+ resJson = null
224
+ }
225
+
226
+ if (resJson?.usage) {
227
+ recordUsage({
228
+ model: body.model,
229
+ accountId: targetAccount.id || targetAccount.label,
230
+ promptTokens: resJson.usage.prompt_tokens,
231
+ completionTokens: resJson.usage.completion_tokens,
232
+ totalTokens: resJson.usage.total_tokens,
233
+ isError: upstreamRes.status >= 400,
234
+ statsPath: pub.statsPath
235
+ })
236
+ }
237
+
238
+ res.statusCode = upstreamRes.status
239
+ res.setHeader('Content-Type', 'application/json; charset=utf-8')
240
+ res.end(resText)
241
+ return
242
+ }
243
+
244
+ // Exhausted all retries
245
+ writeJson(res, 429, {
246
+ error: {
247
+ message: 'All configured accounts in pool exceeded rate limits or quota',
248
+ type: 'rate_limit_exceeded',
249
+ code: 'rate_limit_exceeded',
250
+ details: lastError?.message || null
251
+ }
252
+ })
253
+ },
254
+ }), 'dsh-clinebot: /v1/chat/completions')
255
+
256
+ // 2. GET /dsh-clinebot/v1/models (OpenAI standard)
257
+ ctx.effect(() => ctx.webServer.register({
258
+ kind: 'exact',
259
+ path: '/dsh-clinebot/v1/models',
260
+ handler: async (req, res) => {
261
+ if (req.method !== 'GET') {
262
+ return writeJson(res, 405, { ok: false, error: 'GET only' })
263
+ }
264
+
265
+ if (!isLoopbackAddress(req.socket?.remoteAddress)) {
266
+ return writeJson(res, 403, { ok: false, error: 'Loopback access only' })
267
+ }
268
+ const pub = publicConfig(live())
269
+ const dynamic = Array.isArray(pub.dynamicModels) ? pub.dynamicModels : []
270
+ const custom = Array.isArray(pub.customModels) ? pub.customModels : []
271
+ const overrides = Array.isArray(pub.modelContextOverrides) ? pub.modelContextOverrides : []
272
+ const allModels = getAllModels(dynamic, overrides, custom)
273
+
274
+ const modelsData = allModels.map(m => ({
275
+ id: m.id,
276
+ object: 'model',
277
+ created: 1700000000,
278
+ owned_by: 'clinebot',
279
+ permission: [],
280
+ root: m.id,
281
+ parent: null
282
+ }))
283
+
284
+ writeJson(res, 200, {
285
+ object: 'list',
286
+ data: modelsData
287
+ })
288
+ },
289
+ }), 'dsh-clinebot: /v1/models')
290
+ }
@@ -3,6 +3,7 @@ import { assertTrustedSettingsRequest } from '../access.js'
3
3
  import { publicConfig, plainConfig, Config } from '../config.js'
4
4
  import { buildStatus } from '../provider-sync.js'
5
5
  import { saveCredentialKey, smokeChat, DEFAULT_API_KEY_ENV } from '../cline-client.js'
6
+ import { getStatsSummary, resetStats } from '../stats-storage.js'
6
7
 
7
8
  export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProviderState, triggerAutoDiscover }) {
8
9
  // 1. GET /dsh-clinebot/status
@@ -171,4 +172,37 @@ export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProvider
171
172
  }
172
173
  },
173
174
  }), 'dsh-clinebot: /save-key')
175
+
176
+ // 4. GET /dsh-clinebot/stats — persistent token analytics summary (Issue #129)
177
+ ctx.effect(() => ctx.webServer.register({
178
+ kind: 'exact',
179
+ path: '/dsh-clinebot/stats',
180
+ handler: async (req, res) => {
181
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
182
+ if (!assertTrustedSettingsRequest(req, res)) return
183
+ try {
184
+ const stats = getStatsSummary()
185
+ writeJson(res, 200, { ok: true, stats })
186
+ } catch (err) {
187
+ writeJson(res, 500, { ok: false, error: String(err?.message || err) })
188
+ }
189
+ },
190
+ }), 'dsh-clinebot: /stats')
191
+
192
+ // 5. POST /dsh-clinebot/stats/reset — reset persistent stats (Issue #129)
193
+ ctx.effect(() => ctx.webServer.register({
194
+ kind: 'exact',
195
+ path: '/dsh-clinebot/stats/reset',
196
+ handler: async (req, res) => {
197
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
198
+ if (!assertTrustedSettingsRequest(req, res)) return
199
+ try {
200
+ const pub = publicConfig(live())
201
+ const stats = resetStats(pub.statsPath)
202
+ writeJson(res, 200, { ok: true, stats })
203
+ } catch (err) {
204
+ writeJson(res, 500, { ok: false, error: String(err?.message || err) })
205
+ }
206
+ },
207
+ }), 'dsh-clinebot: /stats/reset')
174
208
  }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Session Router & Least-Used Sticky Balancing (Issue #128)
3
+ *
4
+ * Implements sticky session pinning with least-used quota routing:
5
+ * 1. An account with the highest available quota (lowest percentUsed) is selected on session start.
6
+ * 2. The account is pinned to the session for all subsequent requests in that session.
7
+ * 3. Mid-session failover occurs strictly if the pinned account encounters HTTP 429 or quota depletion.
8
+ * 4. Automatic cooldown recovery restores accounts once their rate-limit / reset timestamp has elapsed.
9
+ */
10
+
11
+ // In-memory sessions: sessionId -> { accountId, assignedAt, lastUsedAt }
12
+ const sessions = new Map()
13
+
14
+ // In-memory cooldowns: accountId -> { until: timestamp, reason: string }
15
+ const cooldowns = new Map()
16
+
17
+ export const MAX_SESSIONS = 1000
18
+ export const SESSION_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
19
+
20
+ /**
21
+ * Evict expired sessions and trim capacity to maxLimit via LRU.
22
+ *
23
+ * @param {number} [now=Date.now()] Current timestamp
24
+ * @param {number} [maxLimit=MAX_SESSIONS] Capacity threshold
25
+ * @param {number} [ttlMs=SESSION_TTL_MS] Expiration TTL
26
+ */
27
+ export function pruneSessions(now = Date.now(), maxLimit = MAX_SESSIONS, ttlMs = SESSION_TTL_MS) {
28
+ for (const [id, s] of sessions.entries()) {
29
+ if (now - s.lastUsedAt > ttlMs) {
30
+ sessions.delete(id)
31
+ }
32
+ }
33
+
34
+ while (sessions.size > maxLimit) {
35
+ let oldestId = null
36
+ let oldestTime = Infinity
37
+ for (const [id, s] of sessions.entries()) {
38
+ if (s.lastUsedAt < oldestTime) {
39
+ oldestTime = s.lastUsedAt
40
+ oldestId = id
41
+ }
42
+ }
43
+ if (oldestId) {
44
+ sessions.delete(oldestId)
45
+ } else {
46
+ break
47
+ }
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Check if an account is currently in cooldown or rate-limited.
53
+ * Performs automatic recovery if the cooldown duration has passed.
54
+ *
55
+ * @param {string} accountId
56
+ * @returns {boolean} true if account is currently blocked
57
+ */
58
+ export function isAccountInCooldown(accountId) {
59
+ if (!accountId) return false
60
+ const entry = cooldowns.get(accountId)
61
+ if (!entry) return false
62
+
63
+ if (Date.now() >= entry.until) {
64
+ cooldowns.delete(accountId)
65
+ return false
66
+ }
67
+ return true
68
+ }
69
+
70
+ /**
71
+ * Mark an account as in cooldown (rate-limited or quota exhausted).
72
+ *
73
+ * @param {string} accountId
74
+ * @param {number} [durationMs=60000] Default 60s cooldown if resetsAt is not provided
75
+ * @param {string|number|null} [resetsAt] ISO string or timestamp of quota window reset
76
+ * @param {string} [reason='rate_limit']
77
+ */
78
+ export function markAccountCooldown(accountId, durationMs = 60000, resetsAt = null, reason = 'rate_limit') {
79
+ if (!accountId) return
80
+
81
+ let until = Date.now() + durationMs
82
+
83
+ if (resetsAt) {
84
+ const resetTime = typeof resetsAt === 'number' ? resetsAt : new Date(resetsAt).getTime()
85
+ if (!Number.isNaN(resetTime) && resetTime > Date.now()) {
86
+ until = resetTime
87
+ }
88
+ }
89
+
90
+ cooldowns.set(accountId, { until, reason })
91
+ }
92
+
93
+ /**
94
+ * Clear cooldown for an account manually.
95
+ *
96
+ * @param {string} accountId
97
+ */
98
+ export function clearAccountCooldown(accountId) {
99
+ if (accountId) {
100
+ cooldowns.delete(accountId)
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Select the best account from the pool based on least-used quota.
106
+ * Lowest percentUsed is prioritized.
107
+ *
108
+ * @param {Array<Object>} accounts Pool of accounts
109
+ * @returns {Object|null} Selected account or null
110
+ */
111
+ export function selectLeastUsedAccount(accounts = []) {
112
+ if (!Array.isArray(accounts) || accounts.length === 0) {
113
+ return null
114
+ }
115
+
116
+ // Filter available accounts that have keys present and are not in cooldown
117
+ const available = accounts.filter(acc => {
118
+ if (!acc || acc.present === false) return false
119
+ return !isAccountInCooldown(acc.id || acc.label)
120
+ })
121
+
122
+ const candidatePool = available.length > 0 ? available : accounts.filter(a => a && a.present !== false)
123
+ if (candidatePool.length === 0) {
124
+ return accounts[0] || null
125
+ }
126
+
127
+ // Sort by lowest percentUsed (least used). If percentUsed is null/undefined, treat as 0% used
128
+ const sorted = [...candidatePool].sort((a, b) => {
129
+ const aUsed = typeof a.percentUsed === 'number' ? a.percentUsed : 0
130
+ const bUsed = typeof b.percentUsed === 'number' ? b.percentUsed : 0
131
+ return aUsed - bUsed
132
+ })
133
+
134
+ return sorted[0]
135
+ }
136
+
137
+ /**
138
+ * Resolve or assign an account for a given session.
139
+ *
140
+ * @param {string|null} sessionId Unique session or conversation identifier
141
+ * @param {Array<Object>} accounts Configured accounts pool
142
+ * @param {string} [defaultActiveId] Fallback active account ID
143
+ * @returns {{ account: Object|null, isReassigned: boolean, reason: string }}
144
+ */
145
+ export function resolveSessionAccount(sessionId, accounts = [], defaultActiveId = '') {
146
+ const safeAccounts = Array.isArray(accounts) ? accounts : []
147
+ if (safeAccounts.length === 0) {
148
+ return { account: null, isReassigned: false, reason: 'empty_pool' }
149
+ }
150
+
151
+ // If no sessionId is provided, use least-used account or default active
152
+ if (!sessionId) {
153
+ const best = selectLeastUsedAccount(safeAccounts) || safeAccounts.find(a => a.id === defaultActiveId) || safeAccounts[0]
154
+ return { account: best, isReassigned: false, reason: 'no_session_id' }
155
+ }
156
+
157
+ let existing = sessions.get(sessionId)
158
+ const now = Date.now()
159
+ if (existing) {
160
+ // TTL eviction check
161
+ if (now - existing.lastUsedAt > SESSION_TTL_MS) {
162
+ sessions.delete(sessionId)
163
+ existing = null
164
+ } else {
165
+ const assigned = safeAccounts.find(a => (a.id || a.label) === existing.accountId)
166
+ // If assigned account is still valid and NOT in cooldown, keep sticky pin
167
+ if (assigned && !isAccountInCooldown(assigned.id || assigned.label)) {
168
+ existing.lastUsedAt = now
169
+ return { account: assigned, isReassigned: false, reason: 'sticky_pinned' }
170
+ }
171
+ // Failover: account was deleted or entered cooldown mid-session
172
+ }
173
+ }
174
+
175
+ // Prune before adding a brand new session if capacity reached
176
+ if (!existing && sessions.size >= MAX_SESSIONS) {
177
+ pruneSessions(now, MAX_SESSIONS - 1, SESSION_TTL_MS)
178
+ }
179
+
180
+ // New session or mid-session failover: pick least-used account
181
+ const chosen = selectLeastUsedAccount(safeAccounts) || safeAccounts.find(a => a.id === defaultActiveId) || safeAccounts[0]
182
+ if (chosen) {
183
+ const chosenId = chosen.id || chosen.label
184
+ sessions.set(sessionId, {
185
+ accountId: chosenId,
186
+ assignedAt: existing ? existing.assignedAt : now,
187
+ lastUsedAt: now
188
+ })
189
+ }
190
+
191
+ return {
192
+ account: chosen,
193
+ isReassigned: Boolean(existing),
194
+ reason: existing ? 'mid_session_failover' : 'new_session_assigned'
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Explicitly release a session.
200
+ *
201
+ * @param {string} sessionId
202
+ */
203
+ export function releaseSession(sessionId) {
204
+ if (sessionId) {
205
+ sessions.delete(sessionId)
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Clear all sessions and cooldowns (for test environments).
211
+ */
212
+ export function resetSessionRouter() {
213
+ sessions.clear()
214
+ cooldowns.clear()
215
+ }
216
+
217
+ /**
218
+ * Return current session router diagnostics.
219
+ */
220
+ export function getSessionRouterStatus() {
221
+ return {
222
+ activeSessionsCount: sessions.size,
223
+ maxSessions: MAX_SESSIONS,
224
+ sessionTtlMs: SESSION_TTL_MS,
225
+ cooldownsCount: cooldowns.size,
226
+ cooldowns: Object.fromEntries(
227
+ Array.from(cooldowns.entries()).map(([k, v]) => [
228
+ k,
229
+ {
230
+ remainingMs: Math.max(0, v.until - Date.now()),
231
+ reason: v.reason
232
+ }
233
+ ])
234
+ )
235
+ }
236
+ }
@@ -1,5 +1,6 @@
1
1
  import { publicConfig, plainConfig, Config } from './config.js'
2
2
  import { resolveActiveAccountKey, formatProgressBar } from './provider-sync.js'
3
+ import { resolveAccountPool, rotateToNextAccount, getLastRotation } from './account-pool.js'
3
4
  import {
4
5
  getAllModels,
5
6
  isVisionModel,
@@ -8,8 +9,6 @@ import {
8
9
  DEFAULT_MODEL_ID,
9
10
  } from './models.js'
10
11
  import {
11
- resolveAccountPool,
12
- rotateToNextAccount,
13
12
  probeHealth,
14
13
  smokeChat,
15
14
  fetchUsageLimits,
@@ -18,7 +17,6 @@ import {
18
17
  clearProbeCache,
19
18
  usageCache,
20
19
  sessionStats,
21
- getLastRotation,
22
20
  } from './cline-client.js'
23
21
 
24
22
  export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderState }) {