@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.
- package/CHANGELOG.md +24 -0
- package/lib/client.js +411 -53
- package/lib/cline-client.js +1 -2
- package/lib/config.js +21 -3
- package/lib/http.js +1 -1
- package/lib/index.js +38 -1
- package/lib/models.js +33 -8
- package/lib/provider-sync.js +50 -25
- package/lib/routes/auth.js +1 -1
- package/lib/routes/proxy.js +290 -0
- package/lib/routes/settings.js +34 -0
- package/lib/session-router.js +236 -0
- package/lib/slash-command.js +1 -3
- package/lib/stats-storage.js +217 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/lib/routes/settings.js
CHANGED
|
@@ -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
|
+
}
|
package/lib/slash-command.js
CHANGED
|
@@ -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 }) {
|