@goodandready/dsh-clinebot 0.4.7 → 0.4.8
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 +9 -0
- package/lib/client.js +411 -53
- package/lib/config.js +21 -3
- package/lib/index.js +35 -0
- package/lib/models.js +31 -6
- package/lib/provider-sync.js +50 -25
- package/lib/routes/proxy.js +262 -0
- package/lib/routes/settings.js +34 -0
- package/lib/session-router.js +188 -0
- package/lib/stats-storage.js +217 -0
- package/package.json +1 -1
|
@@ -0,0 +1,188 @@
|
|
|
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
|
+
/**
|
|
18
|
+
* Check if an account is currently in cooldown or rate-limited.
|
|
19
|
+
* Performs automatic recovery if the cooldown duration has passed.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} accountId
|
|
22
|
+
* @returns {boolean} true if account is currently blocked
|
|
23
|
+
*/
|
|
24
|
+
export function isAccountInCooldown(accountId) {
|
|
25
|
+
if (!accountId) return false
|
|
26
|
+
const entry = cooldowns.get(accountId)
|
|
27
|
+
if (!entry) return false
|
|
28
|
+
|
|
29
|
+
if (Date.now() >= entry.until) {
|
|
30
|
+
cooldowns.delete(accountId)
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
return true
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Mark an account as in cooldown (rate-limited or quota exhausted).
|
|
38
|
+
*
|
|
39
|
+
* @param {string} accountId
|
|
40
|
+
* @param {number} [durationMs=60000] Default 60s cooldown if resetsAt is not provided
|
|
41
|
+
* @param {string|number|null} [resetsAt] ISO string or timestamp of quota window reset
|
|
42
|
+
* @param {string} [reason='rate_limit']
|
|
43
|
+
*/
|
|
44
|
+
export function markAccountCooldown(accountId, durationMs = 60000, resetsAt = null, reason = 'rate_limit') {
|
|
45
|
+
if (!accountId) return
|
|
46
|
+
|
|
47
|
+
let until = Date.now() + durationMs
|
|
48
|
+
|
|
49
|
+
if (resetsAt) {
|
|
50
|
+
const resetTime = typeof resetsAt === 'number' ? resetsAt : new Date(resetsAt).getTime()
|
|
51
|
+
if (!Number.isNaN(resetTime) && resetTime > Date.now()) {
|
|
52
|
+
until = resetTime
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
cooldowns.set(accountId, { until, reason })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Clear cooldown for an account manually.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} accountId
|
|
63
|
+
*/
|
|
64
|
+
export function clearAccountCooldown(accountId) {
|
|
65
|
+
if (accountId) {
|
|
66
|
+
cooldowns.delete(accountId)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Select the best account from the pool based on least-used quota.
|
|
72
|
+
* Lowest percentUsed is prioritized.
|
|
73
|
+
*
|
|
74
|
+
* @param {Array<Object>} accounts Pool of accounts
|
|
75
|
+
* @returns {Object|null} Selected account or null
|
|
76
|
+
*/
|
|
77
|
+
export function selectLeastUsedAccount(accounts = []) {
|
|
78
|
+
if (!Array.isArray(accounts) || accounts.length === 0) {
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Filter available accounts that have keys present and are not in cooldown
|
|
83
|
+
const available = accounts.filter(acc => {
|
|
84
|
+
if (!acc || acc.present === false) return false
|
|
85
|
+
return !isAccountInCooldown(acc.id || acc.label)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const candidatePool = available.length > 0 ? available : accounts.filter(a => a && a.present !== false)
|
|
89
|
+
if (candidatePool.length === 0) {
|
|
90
|
+
return accounts[0] || null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Sort by lowest percentUsed (least used). If percentUsed is null/undefined, treat as 0% used
|
|
94
|
+
const sorted = [...candidatePool].sort((a, b) => {
|
|
95
|
+
const aUsed = typeof a.percentUsed === 'number' ? a.percentUsed : 0
|
|
96
|
+
const bUsed = typeof b.percentUsed === 'number' ? b.percentUsed : 0
|
|
97
|
+
return aUsed - bUsed
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
return sorted[0]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Resolve or assign an account for a given session.
|
|
105
|
+
*
|
|
106
|
+
* @param {string|null} sessionId Unique session or conversation identifier
|
|
107
|
+
* @param {Array<Object>} accounts Configured accounts pool
|
|
108
|
+
* @param {string} [defaultActiveId] Fallback active account ID
|
|
109
|
+
* @returns {{ account: Object|null, isReassigned: boolean, reason: string }}
|
|
110
|
+
*/
|
|
111
|
+
export function resolveSessionAccount(sessionId, accounts = [], defaultActiveId = '') {
|
|
112
|
+
const safeAccounts = Array.isArray(accounts) ? accounts : []
|
|
113
|
+
if (safeAccounts.length === 0) {
|
|
114
|
+
return { account: null, isReassigned: false, reason: 'empty_pool' }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// If no sessionId is provided, use least-used account or default active
|
|
118
|
+
if (!sessionId) {
|
|
119
|
+
const best = selectLeastUsedAccount(safeAccounts) || safeAccounts.find(a => a.id === defaultActiveId) || safeAccounts[0]
|
|
120
|
+
return { account: best, isReassigned: false, reason: 'no_session_id' }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const existing = sessions.get(sessionId)
|
|
124
|
+
if (existing) {
|
|
125
|
+
const assigned = safeAccounts.find(a => (a.id || a.label) === existing.accountId)
|
|
126
|
+
// If assigned account is still valid and NOT in cooldown, keep sticky pin
|
|
127
|
+
if (assigned && !isAccountInCooldown(assigned.id || assigned.label)) {
|
|
128
|
+
existing.lastUsedAt = Date.now()
|
|
129
|
+
return { account: assigned, isReassigned: false, reason: 'sticky_pinned' }
|
|
130
|
+
}
|
|
131
|
+
// Failover: account was deleted or entered cooldown mid-session
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// New session or mid-session failover: pick least-used account
|
|
135
|
+
const chosen = selectLeastUsedAccount(safeAccounts) || safeAccounts.find(a => a.id === defaultActiveId) || safeAccounts[0]
|
|
136
|
+
if (chosen) {
|
|
137
|
+
const chosenId = chosen.id || chosen.label
|
|
138
|
+
sessions.set(sessionId, {
|
|
139
|
+
accountId: chosenId,
|
|
140
|
+
assignedAt: existing ? existing.assignedAt : Date.now(),
|
|
141
|
+
lastUsedAt: Date.now()
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
account: chosen,
|
|
147
|
+
isReassigned: Boolean(existing),
|
|
148
|
+
reason: existing ? 'mid_session_failover' : 'new_session_assigned'
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Explicitly release a session.
|
|
154
|
+
*
|
|
155
|
+
* @param {string} sessionId
|
|
156
|
+
*/
|
|
157
|
+
export function releaseSession(sessionId) {
|
|
158
|
+
if (sessionId) {
|
|
159
|
+
sessions.delete(sessionId)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Clear all sessions and cooldowns (for test environments).
|
|
165
|
+
*/
|
|
166
|
+
export function resetSessionRouter() {
|
|
167
|
+
sessions.clear()
|
|
168
|
+
cooldowns.clear()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Return current session router diagnostics.
|
|
173
|
+
*/
|
|
174
|
+
export function getSessionRouterStatus() {
|
|
175
|
+
return {
|
|
176
|
+
activeSessionsCount: sessions.size,
|
|
177
|
+
cooldownsCount: cooldowns.size,
|
|
178
|
+
cooldowns: Object.fromEntries(
|
|
179
|
+
Array.from(cooldowns.entries()).map(([k, v]) => [
|
|
180
|
+
k,
|
|
181
|
+
{
|
|
182
|
+
remainingMs: Math.max(0, v.until - Date.now()),
|
|
183
|
+
reason: v.reason
|
|
184
|
+
}
|
|
185
|
+
])
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent JSON Token Analytics Storage (Issue #129)
|
|
3
|
+
*
|
|
4
|
+
* Persists token consumption and request telemetry to ~/.dsh/clinebot-stats.json.
|
|
5
|
+
* Survives dsh-web service restarts and tracks historical consumption by day, month, model, and account.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from 'node:fs'
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
|
|
12
|
+
let inMemoryStats = createEmptyStats()
|
|
13
|
+
let saveTimer = null
|
|
14
|
+
let activeStatsPath = ''
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create a fresh, empty stats object.
|
|
18
|
+
*/
|
|
19
|
+
export function createEmptyStats() {
|
|
20
|
+
return {
|
|
21
|
+
version: 1,
|
|
22
|
+
createdAt: Date.now(),
|
|
23
|
+
updatedAt: Date.now(),
|
|
24
|
+
totals: {
|
|
25
|
+
requests: 0,
|
|
26
|
+
successful: 0,
|
|
27
|
+
failed: 0,
|
|
28
|
+
rateLimited429: 0,
|
|
29
|
+
promptTokens: 0,
|
|
30
|
+
completionTokens: 0,
|
|
31
|
+
totalTokens: 0
|
|
32
|
+
},
|
|
33
|
+
byDay: {},
|
|
34
|
+
byMonth: {},
|
|
35
|
+
byModel: {},
|
|
36
|
+
byAccount: {}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve '~' in paths to user's home directory.
|
|
42
|
+
*/
|
|
43
|
+
export function resolvePath(targetPath) {
|
|
44
|
+
if (!targetPath) {
|
|
45
|
+
return path.join(homedir(), '.dsh', 'clinebot-stats.json')
|
|
46
|
+
}
|
|
47
|
+
if (targetPath.startsWith('~/') || targetPath === '~') {
|
|
48
|
+
return path.join(homedir(), targetPath.slice(1))
|
|
49
|
+
}
|
|
50
|
+
return path.resolve(targetPath)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Load stats from disk.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} [customPath]
|
|
57
|
+
* @returns {Object} Loaded stats
|
|
58
|
+
*/
|
|
59
|
+
export function loadStats(customPath) {
|
|
60
|
+
const filePath = resolvePath(customPath)
|
|
61
|
+
activeStatsPath = filePath
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
if (existsSync(filePath)) {
|
|
65
|
+
const raw = readFileSync(filePath, 'utf8')
|
|
66
|
+
const parsed = JSON.parse(raw)
|
|
67
|
+
if (parsed && typeof parsed === 'object' && parsed.totals) {
|
|
68
|
+
inMemoryStats = {
|
|
69
|
+
...createEmptyStats(),
|
|
70
|
+
...parsed,
|
|
71
|
+
totals: { ...createEmptyStats().totals, ...parsed.totals },
|
|
72
|
+
byDay: parsed.byDay || {},
|
|
73
|
+
byMonth: parsed.byMonth || {},
|
|
74
|
+
byModel: parsed.byModel || {},
|
|
75
|
+
byAccount: parsed.byAccount || {}
|
|
76
|
+
}
|
|
77
|
+
return inMemoryStats
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// If reading fails or file is corrupted, fallback to clean stats
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
inMemoryStats = createEmptyStats()
|
|
85
|
+
return inMemoryStats
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Synchronously write stats to disk using atomic rename.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} [customPath]
|
|
92
|
+
*/
|
|
93
|
+
export function saveStatsSync(customPath) {
|
|
94
|
+
const filePath = resolvePath(customPath || activeStatsPath)
|
|
95
|
+
activeStatsPath = filePath
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const dir = path.dirname(filePath)
|
|
99
|
+
if (!existsSync(dir)) {
|
|
100
|
+
mkdirSync(dir, { recursive: true })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
inMemoryStats.updatedAt = Date.now()
|
|
104
|
+
const tempPath = `${filePath}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`
|
|
105
|
+
const data = JSON.stringify(inMemoryStats, null, 2)
|
|
106
|
+
|
|
107
|
+
writeFileSync(tempPath, data, 'utf8')
|
|
108
|
+
renameSync(tempPath, filePath)
|
|
109
|
+
} catch {
|
|
110
|
+
// Best-effort disk persistence
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Queue a debounced write to avoid disk thrashing during rapid streaming tokens.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} [customPath]
|
|
118
|
+
* @param {number} [debounceMs=500]
|
|
119
|
+
*/
|
|
120
|
+
export function queueSaveStats(customPath, debounceMs = 500) {
|
|
121
|
+
if (saveTimer) {
|
|
122
|
+
clearTimeout(saveTimer)
|
|
123
|
+
}
|
|
124
|
+
saveTimer = setTimeout(() => {
|
|
125
|
+
saveTimer = null
|
|
126
|
+
saveStatsSync(customPath)
|
|
127
|
+
}, debounceMs)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Record a completion event with usage metrics.
|
|
132
|
+
*
|
|
133
|
+
* @param {Object} params
|
|
134
|
+
* @param {string} [params.model]
|
|
135
|
+
* @param {string} [params.accountId]
|
|
136
|
+
* @param {number} [params.promptTokens=0]
|
|
137
|
+
* @param {number} [params.completionTokens=0]
|
|
138
|
+
* @param {number} [params.totalTokens=0]
|
|
139
|
+
* @param {boolean} [params.isError=false]
|
|
140
|
+
* @param {boolean} [params.is429=false]
|
|
141
|
+
* @param {string} [params.statsPath]
|
|
142
|
+
*/
|
|
143
|
+
export function recordUsage({
|
|
144
|
+
model = 'unknown',
|
|
145
|
+
accountId = 'default',
|
|
146
|
+
promptTokens = 0,
|
|
147
|
+
completionTokens = 0,
|
|
148
|
+
totalTokens = 0,
|
|
149
|
+
isError = false,
|
|
150
|
+
is429 = false,
|
|
151
|
+
statsPath
|
|
152
|
+
} = {}) {
|
|
153
|
+
const now = new Date()
|
|
154
|
+
const dayKey = now.toISOString().slice(0, 10) // 'YYYY-MM-DD'
|
|
155
|
+
const monthKey = dayKey.slice(0, 7) // 'YYYY-MM'
|
|
156
|
+
|
|
157
|
+
const safePrompt = Math.max(0, Number(promptTokens) || 0)
|
|
158
|
+
const safeComp = Math.max(0, Number(completionTokens) || 0)
|
|
159
|
+
const safeTotal = Math.max(safePrompt + safeComp, Number(totalTokens) || 0)
|
|
160
|
+
|
|
161
|
+
// Totals
|
|
162
|
+
inMemoryStats.totals.requests += 1
|
|
163
|
+
if (isError) {
|
|
164
|
+
inMemoryStats.totals.failed += 1
|
|
165
|
+
} else {
|
|
166
|
+
inMemoryStats.totals.successful += 1
|
|
167
|
+
}
|
|
168
|
+
if (is429) {
|
|
169
|
+
inMemoryStats.totals.rateLimited429 += 1
|
|
170
|
+
}
|
|
171
|
+
inMemoryStats.totals.promptTokens += safePrompt
|
|
172
|
+
inMemoryStats.totals.completionTokens += safeComp
|
|
173
|
+
inMemoryStats.totals.totalTokens += safeTotal
|
|
174
|
+
|
|
175
|
+
// Helper to increment bucket
|
|
176
|
+
function incrementBucket(container, key) {
|
|
177
|
+
if (!container[key]) {
|
|
178
|
+
container[key] = {
|
|
179
|
+
requests: 0,
|
|
180
|
+
promptTokens: 0,
|
|
181
|
+
completionTokens: 0,
|
|
182
|
+
totalTokens: 0
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
container[key].requests += 1
|
|
186
|
+
container[key].promptTokens += safePrompt
|
|
187
|
+
container[key].completionTokens += safeComp
|
|
188
|
+
container[key].totalTokens += safeTotal
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
incrementBucket(inMemoryStats.byDay, dayKey)
|
|
192
|
+
incrementBucket(inMemoryStats.byMonth, monthKey)
|
|
193
|
+
incrementBucket(inMemoryStats.byModel, model)
|
|
194
|
+
incrementBucket(inMemoryStats.byAccount, accountId)
|
|
195
|
+
|
|
196
|
+
queueSaveStats(statsPath)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Retrieve current statistics summary.
|
|
201
|
+
*
|
|
202
|
+
* @returns {Object}
|
|
203
|
+
*/
|
|
204
|
+
export function getStatsSummary() {
|
|
205
|
+
return JSON.parse(JSON.stringify(inMemoryStats))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Reset statistics to initial state and persist.
|
|
210
|
+
*
|
|
211
|
+
* @param {string} [customPath]
|
|
212
|
+
*/
|
|
213
|
+
export function resetStats(customPath) {
|
|
214
|
+
inMemoryStats = createEmptyStats()
|
|
215
|
+
saveStatsSync(customPath)
|
|
216
|
+
return inMemoryStats
|
|
217
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-clinebot",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.8",
|
|
4
4
|
"description": "DeepSeek Harness companion for ClineBot / ClinePass: dynamic subscription models sync, quota exhaustion warnings, session metrics, dedicated settings page, live usage limits, and /cline slash-command.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|