@goodandready/dsh-clinebot 0.5.5 → 0.5.7
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 +23 -5
- package/lib/account-pool.js +43 -2
- package/lib/cline-client.js +12 -2
- package/lib/config.js +5 -1
- package/lib/index.js +56 -20
- package/lib/provider-sync.js +1 -4
- package/lib/routes/accounts.js +34 -5
- package/lib/routes/proxy.js +140 -38
- package/lib/session-router.js +83 -26
- package/lib/stats-storage.js +26 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,15 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.5.4
|
|
4
|
-
|
|
5
|
-
### Fixed
|
|
6
|
-
- **Peer gate on DSH 0.2.0-rc.1** (#58): DSH skips a profile bundle whose `peerDependencies` exclude the running version, so this plugin was absent from the profile with no error in the UI. Every `@deepseek-ai/dsh-*` peer now names both the 0.1.7-rc.2 and 0.2.0-rc.1 lines, because semver does not admit a prerelease of the next minor into a range that does not name it.
|
|
7
|
-
|
|
8
3
|
All notable changes to `@goodandready/dsh-clinebot` will be documented in this file.
|
|
9
4
|
|
|
10
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
11
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
12
7
|
|
|
8
|
+
## [0.5.7] - 2026-09-30
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **Active Account Pinning & Account Identity Unification (Issue #161)**: Resolved issue where proxy router ignored manually pinned active account and routed requests to secondary keys. Unified account identification across `id`, `apiKeyEnv`, and `label` in session routing, round-robin fallback, and cooldown tracking.
|
|
12
|
+
- **Account Pool Quota Metadata Enrichment (Issue #162)**: Provided real-time quota window utilization metadata (`remainingPercent`, `percentUsed`, `resetsAt`) to least-used account routing strategy. Handled unknown/unprobed quotas safely to prevent routing bias towards exhausted accounts.
|
|
13
|
+
- **Pi-AI Provider Session Affinity Header Propagation (Issue #163)**: Enabled `sendSessionAffinityHeaders: true` and `sessionAffinityFormat: 'openrouter'` in `@earendil-works/pi-ai` provider and model compatibility options. Expanded proxy session identification to extract session IDs from `x-session-id`, `x-dsh-session-id`, `session-id`, `x-session-affinity`, and client body parameters, preserving conversation affinity.
|
|
14
|
+
- **Client SSE Disconnection Upstream Abort (Issue #164)**: Monitored client response `close` events during streaming. Aborts upstream fetch and cancels ReadableStream reader immediately when a client disconnects prematurely, avoiding resource waste and orphaned upstream requests. Guarded writes and cleaned up listeners reliably.
|
|
15
|
+
- **Decoupled Connect Timeout & Streaming Idle Watchdog (Issue #165)**: Separated initial HTTP connect / header timeout (`connectTimeoutMs`, default 15s) from active streaming idle timeout (`streamIdleTimeoutMs`, default 30s). Prevented healthy long SSE streams from being cut off by monolithic timeouts. Added rearmable idle watchdog that signals client errors on stalls and accurately accounts failed streams with `isError: true` in token analytics instead of false successes.
|
|
16
|
+
|
|
17
|
+
## [0.5.6] - 2026-09-30
|
|
18
|
+
|
|
19
|
+
### Security
|
|
20
|
+
- **Credential Deletion Scope Enforcement (Issue #155)**: Restricted credential deletion on `DELETE /dsh-clinebot/accounts` and `POST /dsh-clinebot/accounts/delete` strictly to configured secondary accounts matching `^CLINEBOT_API_KEY(_[A-Z0-9]+)?$`. Prohibited deletion of the primary account. Unconfigured or invalid keys receive HTTP 400/404 without calling credentials store. Credential deletion errors return HTTP 500.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- **Atomic Settings Persistence & Volatile Lifecycle (Issue #157)**: Re-architected `settingsApi.replace` and `settingsApi.update` to persist changes via DSH `SettingsForms` *before* mutating live state. If settings service is unavailable or non-writable, explicit errors are thrown. Mutating in-memory configuration now preserves dynamic Volatile references and getters across updates without severance.
|
|
24
|
+
- **Cold Start Persistent Stats Loading & Flush on Dispose (Issue #158)**: Connected `loadStats(statsPath)` to plugin startup lifecycle in `apply()`, preventing cold starts from overwriting historical token metrics with empty counters. Added `flushStats()` effect hook on plugin disposal and automatic corrupted file preservation (`*.corrupt.<timestamp>`) for diagnostics.
|
|
25
|
+
|
|
26
|
+
## [0.5.5] - 2026-09-30
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- **Volatile Boxes Unwrapping in Config Validation (Issue #153)**: Corrected deep unwrapping of Schemastery Volatile boxes and getters when passing dynamic configuration objects through `Config()` validation, preventing schema validation failures.
|
|
30
|
+
|
|
13
31
|
## [0.5.3] - 2026-09-28
|
|
14
32
|
|
|
15
33
|
### Fixed
|
package/lib/account-pool.js
CHANGED
|
@@ -32,6 +32,40 @@ function toPlainSnapshot(cfg) {
|
|
|
32
32
|
return out
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
export function getAccountQuotaSnapshot(accValue) {
|
|
36
|
+
if (!accValue || typeof accValue !== 'string') return null
|
|
37
|
+
const cacheKey = `cline:usage:${accValue.slice(-8)}`
|
|
38
|
+
const cached = usageCache.get(cacheKey)?.data
|
|
39
|
+
if (!cached?.windows) return null
|
|
40
|
+
|
|
41
|
+
const now = Date.now()
|
|
42
|
+
const evaluateWindow = (w) => {
|
|
43
|
+
if (!w || typeof w.percentUsed !== 'number') return null
|
|
44
|
+
if (w.resetsAt) {
|
|
45
|
+
const resetTime = new Date(w.resetsAt).getTime()
|
|
46
|
+
if (!Number.isNaN(resetTime) && now >= resetTime) {
|
|
47
|
+
return 0
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return w.percentUsed
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const fh = evaluateWindow(cached.windows.fiveHour)
|
|
54
|
+
const wk = evaluateWindow(cached.windows.weekly)
|
|
55
|
+
const mo = evaluateWindow(cached.windows.monthly)
|
|
56
|
+
|
|
57
|
+
const activePcts = [fh, wk, mo].filter((p) => typeof p === 'number')
|
|
58
|
+
const percentUsed = activePcts.length > 0 ? Math.max(...activePcts) : null
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
percentUsed,
|
|
62
|
+
fiveHour: fh,
|
|
63
|
+
weekly: wk,
|
|
64
|
+
monthly: mo,
|
|
65
|
+
raw: cached,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
35
69
|
export async function resolveAccountPool(ctx, cfg) {
|
|
36
70
|
const apiKeyEnv = textSetting(cfg?.apiKeyEnv) || DEFAULT_API_KEY_ENV
|
|
37
71
|
const defaultSlot = {
|
|
@@ -48,14 +82,21 @@ export async function resolveAccountPool(ctx, cfg) {
|
|
|
48
82
|
const slot = allSlots[i]
|
|
49
83
|
const envName = textSetting(slot.apiKeyEnv) || (i === 0 ? apiKeyEnv : `CLINEBOT_API_KEY_${i + 1}`)
|
|
50
84
|
const keyInfo = await resolveKeyValue(ctx, envName)
|
|
85
|
+
const slotId = slot.id || (i === 0 ? 'default' : `account-${i + 1}`)
|
|
86
|
+
const isPinned = activeAccount === 'auto'
|
|
87
|
+
? false
|
|
88
|
+
: (activeAccount ? (activeAccount === envName || activeAccount === slotId) : i === 0)
|
|
89
|
+
const quota = keyInfo.value ? getAccountQuotaSnapshot(keyInfo.value) : null
|
|
51
90
|
resolved.push({
|
|
52
|
-
id:
|
|
91
|
+
id: slotId,
|
|
53
92
|
label: textSetting(slot.label) || (i === 0 ? 'Default' : `Account ${i + 1}`),
|
|
54
93
|
apiKeyEnv: envName,
|
|
55
94
|
present: Boolean(keyInfo.value),
|
|
56
95
|
source: keyInfo.source,
|
|
57
96
|
value: keyInfo.value,
|
|
58
|
-
isPinned
|
|
97
|
+
isPinned,
|
|
98
|
+
percentUsed: typeof quota?.percentUsed === 'number' ? quota.percentUsed : null,
|
|
99
|
+
quota,
|
|
59
100
|
})
|
|
60
101
|
}
|
|
61
102
|
|
package/lib/cline-client.js
CHANGED
|
@@ -26,6 +26,7 @@ export const DEFAULT_BASE_URL = 'https://api.cline.bot/api/v1'
|
|
|
26
26
|
export const DEFAULT_API_KEY_ENV = 'CLINEBOT_API_KEY'
|
|
27
27
|
export const DEFAULT_TIMEOUT_MS = 15000
|
|
28
28
|
export const DEFAULT_SMOKE_TIMEOUT_MS = 25000
|
|
29
|
+
export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 30000
|
|
29
30
|
const USAGE_CACHE_TTL_MS = 60000
|
|
30
31
|
|
|
31
32
|
export { PROVIDER_ID, PROVIDER_DISPLAY_NAME, DEFAULT_MODEL_ID }
|
|
@@ -486,13 +487,18 @@ export function buildPiAiProvider({
|
|
|
486
487
|
if (validEfforts.includes(key)) efforts[key] = key === 'off' ? (v === undefined ? null : v) : (typeof v === 'string' && v.trim() ? v.trim() : key)
|
|
487
488
|
}
|
|
488
489
|
}
|
|
490
|
+
const modelCompat = {
|
|
491
|
+
sendSessionAffinityHeaders: true,
|
|
492
|
+
sessionAffinityFormat: 'openrouter',
|
|
493
|
+
}
|
|
489
494
|
if (efforts && Object.keys(efforts).length) {
|
|
490
495
|
if (!('off' in efforts)) efforts.off = null
|
|
491
496
|
res.reasoningEfforts = efforts
|
|
492
|
-
|
|
497
|
+
modelCompat.supportsReasoningEffort = true
|
|
493
498
|
} else {
|
|
494
499
|
res.reasoningEfforts = false
|
|
495
500
|
}
|
|
501
|
+
res.compat = modelCompat
|
|
496
502
|
return res
|
|
497
503
|
})
|
|
498
504
|
|
|
@@ -502,7 +508,11 @@ export function buildPiAiProvider({
|
|
|
502
508
|
baseURL: normalizeBaseUrl(baseUrl),
|
|
503
509
|
apiKeyEnv: apiKeyEnv || DEFAULT_API_KEY_ENV,
|
|
504
510
|
...(apiKey ? { apiKey } : {}),
|
|
505
|
-
compat: {
|
|
511
|
+
compat: {
|
|
512
|
+
supportsReasoningEffort: true,
|
|
513
|
+
sendSessionAffinityHeaders: true,
|
|
514
|
+
sessionAffinityFormat: 'openrouter',
|
|
515
|
+
},
|
|
506
516
|
models: modelList,
|
|
507
517
|
}
|
|
508
518
|
}
|
package/lib/config.js
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
DEFAULT_API_KEY_ENV,
|
|
5
5
|
DEFAULT_TIMEOUT_MS,
|
|
6
6
|
DEFAULT_SMOKE_TIMEOUT_MS,
|
|
7
|
+
DEFAULT_STREAM_IDLE_TIMEOUT_MS,
|
|
7
8
|
normalizeBaseUrl,
|
|
8
9
|
} from './cline-client.js'
|
|
9
10
|
import {
|
|
@@ -76,6 +77,8 @@ export const Config = z.object({
|
|
|
76
77
|
.description('HTTP probe timeout in milliseconds.').volatile(),
|
|
77
78
|
smokeTimeoutMs: z.number().default(DEFAULT_SMOKE_TIMEOUT_MS)
|
|
78
79
|
.description('Timeout for smoke chat completions in milliseconds.').volatile(),
|
|
80
|
+
streamIdleTimeoutMs: z.number().default(DEFAULT_STREAM_IDLE_TIMEOUT_MS)
|
|
81
|
+
.description('Idle timeout between SSE stream chunks in milliseconds.').volatile(),
|
|
79
82
|
modelsCachePath: z.string().default('~/.dsh/clinebot-models-cache.json')
|
|
80
83
|
.description('Local on-disk cache path for models snapshot.').volatile(),
|
|
81
84
|
accounts: z.array(z.object({
|
|
@@ -87,7 +90,7 @@ export const Config = z.object({
|
|
|
87
90
|
.description('Manually pinned active account envName or empty for auto/default.').volatile(),
|
|
88
91
|
})
|
|
89
92
|
|
|
90
|
-
function isVolatileRef(value) {
|
|
93
|
+
export function isVolatileRef(value) {
|
|
91
94
|
return !!value && typeof value === 'object' && !Array.isArray(value) && typeof value.get === 'function'
|
|
92
95
|
}
|
|
93
96
|
|
|
@@ -170,6 +173,7 @@ export function publicConfig(cfg) {
|
|
|
170
173
|
enabledModels: activeIds,
|
|
171
174
|
timeoutMs: Number(cfg?.timeoutMs) || DEFAULT_TIMEOUT_MS,
|
|
172
175
|
smokeTimeoutMs: Number(cfg?.smokeTimeoutMs) || DEFAULT_SMOKE_TIMEOUT_MS,
|
|
176
|
+
streamIdleTimeoutMs: Number(cfg?.streamIdleTimeoutMs) || DEFAULT_STREAM_IDLE_TIMEOUT_MS,
|
|
173
177
|
modelsCachePath: String(cfg?.modelsCachePath || '~/.dsh/clinebot-models-cache.json'),
|
|
174
178
|
accounts: Array.isArray(cfg?.accounts) ? cfg.accounts : [],
|
|
175
179
|
activeAccount: String(cfg?.activeAccount || ''),
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Config, publicConfig, plainConfig, volatileConfig, NS, LLM_PI_AI_NS } from './config.js'
|
|
1
|
+
import { Config, publicConfig, plainConfig, volatileConfig, isVolatileRef, NS, LLM_PI_AI_NS } from './config.js'
|
|
2
2
|
import { registerPluginUpdater } from './updater.js'
|
|
3
3
|
import {
|
|
4
4
|
sessionStats,
|
|
@@ -22,7 +22,7 @@ import { registerModelsRoutes } from './routes/models.js'
|
|
|
22
22
|
import { registerAuthRoutes } from './routes/auth.js'
|
|
23
23
|
import { registerProxyRoutes } from './routes/proxy.js'
|
|
24
24
|
import { registerSlashCommand } from './slash-command.js'
|
|
25
|
-
import { recordUsage } from './stats-storage.js'
|
|
25
|
+
import { recordUsage, loadStats, flushStats } from './stats-storage.js'
|
|
26
26
|
|
|
27
27
|
export const name = '@goodandready/dsh-clinebot'
|
|
28
28
|
export const inject = ['webServer', 'credentials']
|
|
@@ -67,35 +67,62 @@ export function apply(ctx, config) {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
const applyConfigMutations = (target, source) => {
|
|
71
|
+
const targetConfig = typeof target === 'function' ? target() : target
|
|
72
|
+
if (targetConfig && typeof targetConfig === 'object' && !Object.isFrozen(targetConfig)) {
|
|
73
|
+
for (const [key, value] of Object.entries(source)) {
|
|
74
|
+
try {
|
|
75
|
+
if (isVolatileRef(targetConfig[key]) && typeof targetConfig[key].set === 'function') {
|
|
76
|
+
targetConfig[key].set(value)
|
|
77
|
+
} else if (Object.getOwnPropertyDescriptor(targetConfig, key)?.set) {
|
|
78
|
+
targetConfig[key] = value
|
|
79
|
+
} else if (!Object.getOwnPropertyDescriptor(targetConfig, key)?.get) {
|
|
80
|
+
targetConfig[key] = value
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
/* ignore read-only */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
currentConfig = source
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
70
91
|
settingsApi = {
|
|
71
92
|
get: () => live(),
|
|
72
93
|
replace: async (next) => {
|
|
73
|
-
const parsed = Config(structuredClone(plainConfig(next)))
|
|
74
|
-
currentConfig = parsed
|
|
75
94
|
const svc = ctx?.get?.('settings')
|
|
76
|
-
if (svc) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
95
|
+
if (!svc || (typeof svc.replace !== 'function' && typeof svc.update !== 'function')) {
|
|
96
|
+
throw new Error('DSH settings service is unavailable or non-writable')
|
|
97
|
+
}
|
|
98
|
+
const parsed = Config(structuredClone(plainConfig(next)))
|
|
99
|
+
const payload = volatileConfig(parsed)
|
|
100
|
+
const rev = getRevision(svc)
|
|
101
|
+
if (typeof svc.replace === 'function') {
|
|
102
|
+
await svc.replace(NS, payload, rev)
|
|
103
|
+
} else {
|
|
104
|
+
await svc.update(NS, payload, rev)
|
|
83
105
|
}
|
|
106
|
+
applyConfigMutations(currentConfig, parsed)
|
|
107
|
+
loadStats(publicConfig(live()).statsPath)
|
|
84
108
|
await syncProviderState(live())
|
|
85
109
|
return parsed
|
|
86
110
|
},
|
|
87
111
|
update: async (patch) => {
|
|
88
|
-
const merged = Config({ ...publicConfig(live()), ...plainConfig(patch) })
|
|
89
|
-
currentConfig = merged
|
|
90
112
|
const svc = ctx?.get?.('settings')
|
|
91
|
-
if (svc) {
|
|
92
|
-
|
|
93
|
-
if (typeof svc.update === 'function') {
|
|
94
|
-
await svc.update(NS, payload, getRevision(svc))
|
|
95
|
-
} else if (typeof svc.replace === 'function') {
|
|
96
|
-
await svc.replace(NS, payload, getRevision(svc))
|
|
97
|
-
}
|
|
113
|
+
if (!svc || (typeof svc.update !== 'function' && typeof svc.replace !== 'function')) {
|
|
114
|
+
throw new Error('DSH settings service is unavailable or non-writable')
|
|
98
115
|
}
|
|
116
|
+
const merged = Config({ ...publicConfig(live()), ...plainConfig(patch) })
|
|
117
|
+
const payload = volatileConfig(merged)
|
|
118
|
+
const rev = getRevision(svc)
|
|
119
|
+
if (typeof svc.update === 'function') {
|
|
120
|
+
await svc.update(NS, payload, rev)
|
|
121
|
+
} else {
|
|
122
|
+
await svc.replace(NS, payload, rev)
|
|
123
|
+
}
|
|
124
|
+
applyConfigMutations(currentConfig, merged)
|
|
125
|
+
loadStats(publicConfig(live()).statsPath)
|
|
99
126
|
await syncProviderState(live())
|
|
100
127
|
return merged
|
|
101
128
|
},
|
|
@@ -106,6 +133,15 @@ export function apply(ctx, config) {
|
|
|
106
133
|
},
|
|
107
134
|
}
|
|
108
135
|
|
|
136
|
+
loadStats(publicConfig(live()).statsPath)
|
|
137
|
+
if (typeof ctx.effect === 'function') {
|
|
138
|
+
ctx.effect(() => {
|
|
139
|
+
return () => {
|
|
140
|
+
flushStats(publicConfig(live()).statsPath)
|
|
141
|
+
}
|
|
142
|
+
}, 'dsh-clinebot: stats-storage')
|
|
143
|
+
}
|
|
144
|
+
|
|
109
145
|
syncProviderState(live())
|
|
110
146
|
if (typeof ctx.effect === 'function') {
|
|
111
147
|
ctx.effect(() => {
|
package/lib/provider-sync.js
CHANGED
|
@@ -161,9 +161,6 @@ export async function buildStatus(ctx, cfg) {
|
|
|
161
161
|
source: activeAcc.source || key.source,
|
|
162
162
|
},
|
|
163
163
|
accounts: pool.map((acc) => {
|
|
164
|
-
const cacheKey = `cline:usage:${(acc.value || '').slice(-8)}`
|
|
165
|
-
const cached = usageCache.get(cacheKey)?.data
|
|
166
|
-
const pct = cached?.windows?.weekly?.percentUsed ?? cached?.windows?.monthly?.percentUsed ?? cached?.windows?.fiveHour?.percentUsed ?? null
|
|
167
164
|
return {
|
|
168
165
|
id: acc.id,
|
|
169
166
|
label: acc.label,
|
|
@@ -171,7 +168,7 @@ export async function buildStatus(ctx, cfg) {
|
|
|
171
168
|
present: acc.present,
|
|
172
169
|
source: acc.source,
|
|
173
170
|
isPinned: acc.isPinned,
|
|
174
|
-
percentUsed: typeof
|
|
171
|
+
percentUsed: typeof acc.percentUsed === 'number' ? acc.percentUsed : null,
|
|
175
172
|
}
|
|
176
173
|
}),
|
|
177
174
|
activeAccount: activeAcc.apiKeyEnv,
|
package/lib/routes/accounts.js
CHANGED
|
@@ -127,17 +127,50 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
|
|
|
127
127
|
body = {}
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
|
-
|
|
130
|
+
let queryApiKeyEnv = ''
|
|
131
|
+
try {
|
|
132
|
+
const u = new URL(req.url, 'http://127.0.0.1')
|
|
133
|
+
queryApiKeyEnv = u.searchParams.get('apiKeyEnv') || ''
|
|
134
|
+
} catch {
|
|
135
|
+
queryApiKeyEnv = ''
|
|
136
|
+
}
|
|
137
|
+
const targetEnv = String(targetEnvFromUrl || body.apiKeyEnv || queryApiKeyEnv || '').trim()
|
|
131
138
|
if (!targetEnv) {
|
|
132
139
|
return writeJson(res, 400, { ok: false, error: 'apiKeyEnv is required' })
|
|
133
140
|
}
|
|
134
141
|
|
|
142
|
+
const isDefaultPattern = /^CLINEBOT_API_KEY(_[A-Z0-9]+)?$/.test(targetEnv)
|
|
143
|
+
if (!isDefaultPattern) {
|
|
144
|
+
return writeJson(res, 400, {
|
|
145
|
+
ok: false,
|
|
146
|
+
error: `Disallowed apiKeyEnv: "${targetEnv}". Must match ^CLINEBOT_API_KEY(_[A-Z0-9]+)?$.`,
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
135
150
|
const curr = plainConfig(live())
|
|
136
151
|
if (targetEnv === curr.apiKeyEnv || targetEnv === DEFAULT_API_KEY_ENV) {
|
|
137
152
|
return writeJson(res, 400, { ok: false, error: 'Cannot delete the primary account' })
|
|
138
153
|
}
|
|
139
154
|
|
|
140
155
|
const existingAccounts = Array.isArray(curr.accounts) ? curr.accounts : []
|
|
156
|
+
const accountToDelete = existingAccounts.find((a) => a && a.apiKeyEnv === targetEnv)
|
|
157
|
+
if (!accountToDelete) {
|
|
158
|
+
return writeJson(res, 404, {
|
|
159
|
+
ok: false,
|
|
160
|
+
error: `Account "${targetEnv}" not found in accounts pool`,
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (body.deleteSecret) {
|
|
165
|
+
const delRes = await deleteCredentialKey(ctx, targetEnv)
|
|
166
|
+
if (!delRes || !delRes.ok) {
|
|
167
|
+
return writeJson(res, 500, {
|
|
168
|
+
ok: false,
|
|
169
|
+
error: `Failed to delete secret for account "${targetEnv}" from credentials store`,
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
141
174
|
const nextAccounts = existingAccounts.filter((a) => a && a.apiKeyEnv !== targetEnv)
|
|
142
175
|
let activeAccount = curr.activeAccount
|
|
143
176
|
if (activeAccount === targetEnv) {
|
|
@@ -148,10 +181,6 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
|
|
|
148
181
|
await settingsApi.replace(next)
|
|
149
182
|
await syncProviderState(next)
|
|
150
183
|
|
|
151
|
-
if (body.deleteSecret) {
|
|
152
|
-
await deleteCredentialKey(ctx, targetEnv)
|
|
153
|
-
}
|
|
154
|
-
|
|
155
184
|
clearUsageCache()
|
|
156
185
|
clearProbeCache()
|
|
157
186
|
|
package/lib/routes/proxy.js
CHANGED
|
@@ -26,7 +26,7 @@ import { getLocalProxyToken } from '../proxy-token.js'
|
|
|
26
26
|
const require = createRequire(import.meta.url)
|
|
27
27
|
let packageVersion = '0.5.3'
|
|
28
28
|
try {
|
|
29
|
-
packageVersion = require('../../package.json').version || '0.5.
|
|
29
|
+
packageVersion = require('../../package.json').version || '0.5.6'
|
|
30
30
|
} catch (err) {
|
|
31
31
|
/* fallback to default version if manifest is unreadable */
|
|
32
32
|
}
|
|
@@ -106,6 +106,11 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
|
|
|
106
106
|
const sessionId = String(
|
|
107
107
|
req.headers['x-dsh-session-id'] ||
|
|
108
108
|
req.headers['x-session-id'] ||
|
|
109
|
+
req.headers['session-id'] ||
|
|
110
|
+
req.headers['session_id'] ||
|
|
111
|
+
req.headers['x-session-affinity'] ||
|
|
112
|
+
req.headers['x-client-request-id'] ||
|
|
113
|
+
body?.session_id ||
|
|
109
114
|
body?.conversation_id ||
|
|
110
115
|
body?.chat_id ||
|
|
111
116
|
body?.user ||
|
|
@@ -127,6 +132,24 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
|
|
|
127
132
|
const isStreaming = Boolean(body.stream)
|
|
128
133
|
const configuredAccounts = pool.filter(a => a.present && a.value)
|
|
129
134
|
|
|
135
|
+
const clientAbortCtrl = new AbortController()
|
|
136
|
+
let clientClosed = false
|
|
137
|
+
const onClientClose = () => {
|
|
138
|
+
clientClosed = true
|
|
139
|
+
if (!res.writableFinished) {
|
|
140
|
+
clientAbortCtrl.abort()
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (typeof res.on === 'function') res.on('close', onClientClose)
|
|
144
|
+
if (typeof req.on === 'function') req.on('close', onClientClose)
|
|
145
|
+
|
|
146
|
+
const cleanupClientListeners = () => {
|
|
147
|
+
if (typeof res.off === 'function') res.off('close', onClientClose)
|
|
148
|
+
else if (typeof res.removeListener === 'function') res.removeListener('close', onClientClose)
|
|
149
|
+
if (typeof req.off === 'function') req.off('close', onClientClose)
|
|
150
|
+
else if (typeof req.removeListener === 'function') req.removeListener('close', onClientClose)
|
|
151
|
+
}
|
|
152
|
+
|
|
130
153
|
const maxRetries = Math.max(1, configuredAccounts.length)
|
|
131
154
|
let attempt = 0
|
|
132
155
|
let lastError = null
|
|
@@ -135,10 +158,16 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
|
|
|
135
158
|
while (attempt < maxRetries) {
|
|
136
159
|
attempt += 1
|
|
137
160
|
|
|
161
|
+
if (clientAbortCtrl.signal.aborted || clientClosed) {
|
|
162
|
+
cleanupClientListeners()
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
|
|
138
166
|
const routeResult = resolveSessionAccount(sessionId, pool, pub.activeAccount)
|
|
139
167
|
const targetAccount = routeResult.account
|
|
140
168
|
|
|
141
169
|
if (!targetAccount || !targetAccount.value) {
|
|
170
|
+
cleanupClientListeners()
|
|
142
171
|
return writeJson(res, 503, {
|
|
143
172
|
error: {
|
|
144
173
|
message: 'No available ClinePass API keys configured in pool',
|
|
@@ -156,16 +185,17 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
|
|
|
156
185
|
'User-Agent': USER_AGENT
|
|
157
186
|
}
|
|
158
187
|
|
|
159
|
-
const
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
|
|
188
|
+
const connectTimeoutMs = Math.max(50, Number(pub.connectTimeoutMs || pub.timeoutMs) || 15000)
|
|
189
|
+
const streamIdleTimeoutMs = Math.max(50, Number(pub.streamIdleTimeoutMs || pub.idleTimeoutMs) || 30000)
|
|
190
|
+
|
|
191
|
+
const connectAbortCtrl = new AbortController()
|
|
192
|
+
let connectTimedOut = false
|
|
193
|
+
const connectTimer = setTimeout(() => {
|
|
194
|
+
connectTimedOut = true
|
|
195
|
+
connectAbortCtrl.abort(new Error(`Upstream connect timeout after ${connectTimeoutMs}ms`))
|
|
196
|
+
}, connectTimeoutMs)
|
|
197
|
+
|
|
198
|
+
const fetchSignal = AbortSignal.any([clientAbortCtrl.signal, connectAbortCtrl.signal])
|
|
169
199
|
|
|
170
200
|
let upstreamRes
|
|
171
201
|
try {
|
|
@@ -177,25 +207,29 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
|
|
|
177
207
|
keepalive: true
|
|
178
208
|
})
|
|
179
209
|
} catch (fetchErr) {
|
|
180
|
-
|
|
181
|
-
|
|
210
|
+
clearTimeout(connectTimer)
|
|
211
|
+
if (clientAbortCtrl.signal.aborted || clientClosed) {
|
|
212
|
+
cleanupClientListeners()
|
|
182
213
|
return
|
|
183
214
|
}
|
|
184
215
|
lastError = fetchErr
|
|
185
|
-
const isTimeout =
|
|
216
|
+
const isTimeout = connectTimedOut || connectAbortCtrl.signal.aborted || fetchErr.name === 'TimeoutError' || String(fetchErr).includes('timeout')
|
|
186
217
|
if (isTimeout) {
|
|
187
218
|
hadTimeout = true
|
|
188
|
-
const accountId = targetAccount.id || targetAccount.label
|
|
219
|
+
const accountId = targetAccount.apiKeyEnv || targetAccount.id || targetAccount.label
|
|
189
220
|
markAccountCooldown(accountId, 60000, null, 'upstream_timeout')
|
|
190
|
-
|
|
221
|
+
if (targetAccount.id && targetAccount.id !== accountId) markAccountCooldown(targetAccount.id, 60000, null, 'upstream_timeout')
|
|
222
|
+
ctx?.logger?.warn?.(`[dsh-clinebot:proxy] Account "${accountId}" upstream connect timed out after ${connectTimeoutMs}ms, attempting failover (attempt ${attempt}/${maxRetries})`)
|
|
191
223
|
}
|
|
192
224
|
continue
|
|
193
225
|
}
|
|
226
|
+
clearTimeout(connectTimer)
|
|
194
227
|
|
|
195
228
|
// If upstream rejected with 429 or 402 quota exhaustion before streaming, failover!
|
|
196
229
|
if (upstreamRes.status === 429 || upstreamRes.status === 402) {
|
|
197
|
-
const accountId = targetAccount.id || targetAccount.label
|
|
230
|
+
const accountId = targetAccount.apiKeyEnv || targetAccount.id || targetAccount.label
|
|
198
231
|
markAccountCooldown(accountId, 60000, null, 'upstream_429')
|
|
232
|
+
if (targetAccount.id && targetAccount.id !== accountId) markAccountCooldown(targetAccount.id, 60000, null, 'upstream_429')
|
|
199
233
|
recordUsage({
|
|
200
234
|
model: body.model,
|
|
201
235
|
accountId,
|
|
@@ -221,6 +255,8 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
|
|
|
221
255
|
let completionTokens = 0
|
|
222
256
|
let totalTokens = 0
|
|
223
257
|
let buffer = ''
|
|
258
|
+
let streamError = null
|
|
259
|
+
let streamCompletedCleanly = false
|
|
224
260
|
|
|
225
261
|
const reader = upstreamRes.body.getReader()
|
|
226
262
|
const decoder = new TextDecoder('utf8')
|
|
@@ -229,22 +265,67 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
|
|
|
229
265
|
const onStreamAbort = () => {
|
|
230
266
|
streamAborted = true
|
|
231
267
|
try {
|
|
232
|
-
reader.cancel().catch(() => {})
|
|
233
|
-
} catch {
|
|
234
|
-
|
|
268
|
+
reader.cancel('client closed response').catch(() => {})
|
|
269
|
+
} catch { /* reader already cancelled or closed */ }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (clientAbortCtrl.signal.aborted || clientClosed) {
|
|
273
|
+
onStreamAbort()
|
|
274
|
+
} else {
|
|
275
|
+
clientAbortCtrl.signal.addEventListener('abort', onStreamAbort, { once: true })
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
let idleTimer = null
|
|
279
|
+
let idleTimedOut = false
|
|
280
|
+
const resetIdleTimer = () => {
|
|
281
|
+
if (idleTimer) clearTimeout(idleTimer)
|
|
282
|
+
idleTimer = setTimeout(() => {
|
|
283
|
+
idleTimedOut = true
|
|
284
|
+
streamError = new Error(`Stream idle timeout after ${streamIdleTimeoutMs}ms`)
|
|
285
|
+
try {
|
|
286
|
+
reader.cancel('stream idle timeout').catch(() => {})
|
|
287
|
+
} catch { /* reader already cancelled or closed */ }
|
|
288
|
+
}, streamIdleTimeoutMs)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const clearIdleTimer = () => {
|
|
292
|
+
if (idleTimer) {
|
|
293
|
+
clearTimeout(idleTimer)
|
|
294
|
+
idleTimer = null
|
|
235
295
|
}
|
|
236
296
|
}
|
|
237
|
-
|
|
238
|
-
|
|
297
|
+
|
|
298
|
+
let totalTimer = null
|
|
299
|
+
if (pub.streamTotalTimeoutMs && Number(pub.streamTotalTimeoutMs) > 0) {
|
|
300
|
+
const totalMs = Number(pub.streamTotalTimeoutMs)
|
|
301
|
+
totalTimer = setTimeout(() => {
|
|
302
|
+
streamError = new Error(`Stream total timeout after ${totalMs}ms`)
|
|
303
|
+
try {
|
|
304
|
+
reader.cancel('stream total timeout').catch(() => {})
|
|
305
|
+
} catch { /* reader already cancelled or closed */ }
|
|
306
|
+
}, totalMs)
|
|
239
307
|
}
|
|
240
308
|
|
|
241
309
|
try {
|
|
310
|
+
resetIdleTimer()
|
|
242
311
|
while (true) {
|
|
312
|
+
if (streamAborted || clientAbortCtrl.signal.aborted || clientClosed) break
|
|
243
313
|
const { done, value } = await reader.read()
|
|
244
|
-
if (
|
|
314
|
+
if (idleTimedOut || streamError) break
|
|
315
|
+
if (done || streamAborted) {
|
|
316
|
+
if (done && !streamAborted && !clientClosed && !idleTimedOut && !streamError) {
|
|
317
|
+
streamCompletedCleanly = true
|
|
318
|
+
}
|
|
319
|
+
break
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
resetIdleTimer()
|
|
245
323
|
|
|
246
324
|
const chunkStr = decoder.decode(value, { stream: true })
|
|
247
|
-
res.
|
|
325
|
+
if (!res.destroyed && !res.writableEnded) {
|
|
326
|
+
res.write(value)
|
|
327
|
+
}
|
|
328
|
+
if (streamAborted || clientAbortCtrl.signal.aborted || clientClosed) break
|
|
248
329
|
|
|
249
330
|
// Parse SSE lines for usage metrics
|
|
250
331
|
buffer += chunkStr
|
|
@@ -268,14 +349,34 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
|
|
|
268
349
|
}
|
|
269
350
|
}
|
|
270
351
|
} catch (streamErr) {
|
|
352
|
+
streamError = streamErr
|
|
271
353
|
ctx?.logger?.warn?.('[dsh-clinebot:proxy] Stream read error: ' + (streamErr?.message || streamErr))
|
|
272
354
|
} finally {
|
|
355
|
+
clearIdleTimer()
|
|
356
|
+
if (totalTimer) clearTimeout(totalTimer)
|
|
357
|
+
clientAbortCtrl.signal.removeEventListener('abort', onStreamAbort)
|
|
358
|
+
cleanupClientListeners()
|
|
273
359
|
try {
|
|
274
360
|
reader.releaseLock()
|
|
275
|
-
} catch (err) {
|
|
276
|
-
|
|
361
|
+
} catch (err) { /* ignore lock release error if reader closed */ }
|
|
362
|
+
|
|
363
|
+
const isStreamFailed = Boolean(streamError || idleTimedOut || clientClosed || clientAbortCtrl.signal.aborted || !streamCompletedCleanly)
|
|
364
|
+
|
|
365
|
+
if (!res.destroyed && !res.writableEnded) {
|
|
366
|
+
if (isStreamFailed && (idleTimedOut || streamError)) {
|
|
367
|
+
try {
|
|
368
|
+
const errPayload = JSON.stringify({
|
|
369
|
+
error: {
|
|
370
|
+
message: streamError?.message || 'Stream interrupted or timed out',
|
|
371
|
+
type: 'stream_error',
|
|
372
|
+
code: idleTimedOut ? 'stream_idle_timeout' : 'stream_error'
|
|
373
|
+
}
|
|
374
|
+
})
|
|
375
|
+
res.write(`data: ${errPayload}\n\n`)
|
|
376
|
+
} catch { /* reader already cancelled or closed */ }
|
|
377
|
+
}
|
|
378
|
+
res.end()
|
|
277
379
|
}
|
|
278
|
-
res.end()
|
|
279
380
|
|
|
280
381
|
recordUsage({
|
|
281
382
|
model: body.model,
|
|
@@ -283,6 +384,7 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
|
|
|
283
384
|
promptTokens,
|
|
284
385
|
completionTokens,
|
|
285
386
|
totalTokens: totalTokens || (promptTokens + completionTokens),
|
|
387
|
+
isError: isStreamFailed,
|
|
286
388
|
statsPath: pub.statsPath
|
|
287
389
|
})
|
|
288
390
|
}
|
|
@@ -298,17 +400,16 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
|
|
|
298
400
|
resJson = null
|
|
299
401
|
}
|
|
300
402
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
}
|
|
403
|
+
cleanupClientListeners()
|
|
404
|
+
recordUsage({
|
|
405
|
+
model: body.model,
|
|
406
|
+
accountId: targetAccount.id || targetAccount.label,
|
|
407
|
+
promptTokens: resJson?.usage?.prompt_tokens || 0,
|
|
408
|
+
completionTokens: resJson?.usage?.completion_tokens || 0,
|
|
409
|
+
totalTokens: resJson?.usage?.total_tokens || 0,
|
|
410
|
+
isError: upstreamRes.status >= 400,
|
|
411
|
+
statsPath: pub.statsPath
|
|
412
|
+
})
|
|
312
413
|
|
|
313
414
|
res.statusCode = upstreamRes.status
|
|
314
415
|
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
@@ -316,6 +417,7 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
|
|
|
316
417
|
return
|
|
317
418
|
}
|
|
318
419
|
|
|
420
|
+
cleanupClientListeners()
|
|
319
421
|
// Exhausted all retries
|
|
320
422
|
if (hadTimeout) {
|
|
321
423
|
return writeJson(res, 504, {
|
package/lib/session-router.js
CHANGED
|
@@ -101,6 +101,20 @@ export function clearAccountCooldown(accountId) {
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
export function matchesAccount(acc, identifier) {
|
|
105
|
+
if (!acc || !identifier) return false
|
|
106
|
+
return acc.id === identifier || acc.apiKeyEnv === identifier || acc.label === identifier
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function isAccountBlocked(acc) {
|
|
110
|
+
if (!acc) return false
|
|
111
|
+
return Boolean(
|
|
112
|
+
(acc.id && isAccountInCooldown(acc.id)) ||
|
|
113
|
+
(acc.apiKeyEnv && isAccountInCooldown(acc.apiKeyEnv)) ||
|
|
114
|
+
(acc.label && isAccountInCooldown(acc.label))
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
104
118
|
/**
|
|
105
119
|
* Select the best account from the pool based on least-used quota.
|
|
106
120
|
* Lowest percentUsed is prioritized.
|
|
@@ -108,6 +122,14 @@ export function clearAccountCooldown(accountId) {
|
|
|
108
122
|
* @param {Array<Object>} accounts Pool of accounts
|
|
109
123
|
* @returns {Object|null} Selected account or null
|
|
110
124
|
*/
|
|
125
|
+
export function getAccountUsageMetric(acc) {
|
|
126
|
+
if (typeof acc?.percentUsed === 'number') {
|
|
127
|
+
return acc.percentUsed
|
|
128
|
+
}
|
|
129
|
+
// Unknown quota is treated as 50% neutral midpoint rather than assuming 0% completely free
|
|
130
|
+
return 50
|
|
131
|
+
}
|
|
132
|
+
|
|
111
133
|
export function selectLeastUsedAccount(accounts = []) {
|
|
112
134
|
if (!Array.isArray(accounts) || accounts.length === 0) {
|
|
113
135
|
return null
|
|
@@ -116,7 +138,7 @@ export function selectLeastUsedAccount(accounts = []) {
|
|
|
116
138
|
// Filter available accounts that have keys present and are not in cooldown
|
|
117
139
|
const available = accounts.filter(acc => {
|
|
118
140
|
if (!acc || acc.present === false) return false
|
|
119
|
-
return !
|
|
141
|
+
return !isAccountBlocked(acc)
|
|
120
142
|
})
|
|
121
143
|
|
|
122
144
|
const candidatePool = available.length > 0 ? available : accounts.filter(a => a && a.present !== false)
|
|
@@ -124,10 +146,10 @@ export function selectLeastUsedAccount(accounts = []) {
|
|
|
124
146
|
return accounts[0] || null
|
|
125
147
|
}
|
|
126
148
|
|
|
127
|
-
// Sort by lowest percentUsed (least used).
|
|
149
|
+
// Sort by lowest percentUsed (least used). Unknown quota is treated as neutral 50%
|
|
128
150
|
const sorted = [...candidatePool].sort((a, b) => {
|
|
129
|
-
const aUsed =
|
|
130
|
-
const bUsed =
|
|
151
|
+
const aUsed = getAccountUsageMetric(a)
|
|
152
|
+
const bUsed = getAccountUsageMetric(b)
|
|
131
153
|
return aUsed - bUsed
|
|
132
154
|
})
|
|
133
155
|
|
|
@@ -139,50 +161,85 @@ export function selectLeastUsedAccount(accounts = []) {
|
|
|
139
161
|
*
|
|
140
162
|
* @param {string|null} sessionId Unique session or conversation identifier
|
|
141
163
|
* @param {Array<Object>} accounts Configured accounts pool
|
|
142
|
-
* @param {string} [defaultActiveId]
|
|
164
|
+
* @param {string} [defaultActiveId] Explicit active account ID, envName or 'auto'
|
|
143
165
|
* @returns {{ account: Object|null, isReassigned: boolean, reason: string }}
|
|
144
166
|
*/
|
|
145
167
|
export function resolveSessionAccount(sessionId, accounts = [], defaultActiveId = '') {
|
|
146
|
-
const safeAccounts = Array.isArray(accounts) ? accounts : []
|
|
168
|
+
const safeAccounts = Array.isArray(accounts) ? accounts.filter(a => a && a.present !== false) : []
|
|
147
169
|
if (safeAccounts.length === 0) {
|
|
148
|
-
|
|
170
|
+
const fallback = Array.isArray(accounts) && accounts.length > 0 ? accounts[0] : null
|
|
171
|
+
return { account: fallback, isReassigned: false, reason: 'empty_pool' }
|
|
149
172
|
}
|
|
150
173
|
|
|
151
|
-
//
|
|
174
|
+
// Determine explicit pin / active account policy
|
|
175
|
+
const isAutoMode = defaultActiveId === 'auto'
|
|
176
|
+
let pinnedAccount = null
|
|
177
|
+
if (!isAutoMode) {
|
|
178
|
+
if (defaultActiveId) {
|
|
179
|
+
pinnedAccount = safeAccounts.find(a => matchesAccount(a, defaultActiveId)) || null
|
|
180
|
+
}
|
|
181
|
+
if (!pinnedAccount) {
|
|
182
|
+
pinnedAccount = safeAccounts.find(a => a.isPinned) || null
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const pinnedAvailable = pinnedAccount && !isAccountBlocked(pinnedAccount) ? pinnedAccount : null
|
|
187
|
+
|
|
188
|
+
// If no sessionId provided:
|
|
152
189
|
if (!sessionId) {
|
|
153
|
-
|
|
154
|
-
|
|
190
|
+
if (pinnedAvailable) {
|
|
191
|
+
return { account: pinnedAvailable, isReassigned: false, reason: 'pinned_active' }
|
|
192
|
+
}
|
|
193
|
+
const best = selectLeastUsedAccount(safeAccounts) || safeAccounts[0]
|
|
194
|
+
return { account: best, isReassigned: false, reason: pinnedAccount ? 'pinned_in_cooldown_fallback' : 'no_session_id' }
|
|
155
195
|
}
|
|
156
196
|
|
|
157
|
-
let existing = sessions.get(sessionId)
|
|
158
197
|
const now = Date.now()
|
|
198
|
+
let existing = sessions.get(sessionId)
|
|
199
|
+
|
|
159
200
|
if (existing) {
|
|
160
|
-
// TTL
|
|
201
|
+
// Check TTL
|
|
161
202
|
if (now - existing.lastUsedAt > SESSION_TTL_MS) {
|
|
162
203
|
sessions.delete(sessionId)
|
|
163
204
|
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
205
|
}
|
|
173
206
|
}
|
|
174
207
|
|
|
175
|
-
//
|
|
208
|
+
// If manual pin is configured and available:
|
|
209
|
+
if (pinnedAvailable) {
|
|
210
|
+
const reassigned = Boolean(existing && !matchesAccount(pinnedAvailable, existing.accountId))
|
|
211
|
+
sessions.set(sessionId, {
|
|
212
|
+
accountId: pinnedAvailable.apiKeyEnv || pinnedAvailable.id,
|
|
213
|
+
assignedAt: existing ? existing.assignedAt : now,
|
|
214
|
+
lastUsedAt: now
|
|
215
|
+
})
|
|
216
|
+
return {
|
|
217
|
+
account: pinnedAvailable,
|
|
218
|
+
isReassigned: reassigned,
|
|
219
|
+
reason: reassigned ? 'pinned_override' : (existing ? 'sticky_pinned' : 'pinned_active')
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// If in auto mode or pinned account is unavailable:
|
|
224
|
+
if (existing) {
|
|
225
|
+
const assigned = safeAccounts.find(a => matchesAccount(a, existing.accountId))
|
|
226
|
+
if (assigned && !isAccountBlocked(assigned)) {
|
|
227
|
+
existing.lastUsedAt = now
|
|
228
|
+
return { account: assigned, isReassigned: false, reason: 'sticky_pinned' }
|
|
229
|
+
}
|
|
230
|
+
// Otherwise, failover is needed
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Prune if needed
|
|
176
234
|
if (!existing && sessions.size >= MAX_SESSIONS) {
|
|
177
235
|
pruneSessions(now, MAX_SESSIONS - 1, SESSION_TTL_MS)
|
|
178
236
|
}
|
|
179
237
|
|
|
180
|
-
//
|
|
181
|
-
const chosen = selectLeastUsedAccount(safeAccounts) || safeAccounts
|
|
238
|
+
// Pick least-used available account
|
|
239
|
+
const chosen = selectLeastUsedAccount(safeAccounts) || safeAccounts[0]
|
|
182
240
|
if (chosen) {
|
|
183
|
-
const chosenId = chosen.id || chosen.label
|
|
184
241
|
sessions.set(sessionId, {
|
|
185
|
-
accountId:
|
|
242
|
+
accountId: chosen.apiKeyEnv || chosen.id,
|
|
186
243
|
assignedAt: existing ? existing.assignedAt : now,
|
|
187
244
|
lastUsedAt: now
|
|
188
245
|
})
|
|
@@ -191,7 +248,7 @@ export function resolveSessionAccount(sessionId, accounts = [], defaultActiveId
|
|
|
191
248
|
return {
|
|
192
249
|
account: chosen,
|
|
193
250
|
isReassigned: Boolean(existing),
|
|
194
|
-
reason: existing ? 'mid_session_failover' : 'new_session_assigned'
|
|
251
|
+
reason: existing ? 'mid_session_failover' : (pinnedAccount ? 'pinned_in_cooldown_fallback' : 'new_session_assigned')
|
|
195
252
|
}
|
|
196
253
|
}
|
|
197
254
|
|
package/lib/stats-storage.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Survives dsh-web service restarts and tracks historical consumption by day, month, model, and account.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from 'node:fs'
|
|
8
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, copyFileSync } from 'node:fs'
|
|
9
9
|
import { homedir } from 'node:os'
|
|
10
10
|
import path from 'node:path'
|
|
11
11
|
|
|
@@ -84,9 +84,9 @@ export function resolvePath(targetPath) {
|
|
|
84
84
|
* @returns {Object} Loaded stats
|
|
85
85
|
*/
|
|
86
86
|
export function loadStats(customPath) {
|
|
87
|
+
const filePath = resolvePath(customPath)
|
|
88
|
+
activeStatsPath = filePath
|
|
87
89
|
try {
|
|
88
|
-
const filePath = resolvePath(customPath)
|
|
89
|
-
activeStatsPath = filePath
|
|
90
90
|
if (existsSync(filePath)) {
|
|
91
91
|
const raw = readFileSync(filePath, 'utf8')
|
|
92
92
|
const parsed = JSON.parse(raw)
|
|
@@ -103,14 +103,35 @@ export function loadStats(customPath) {
|
|
|
103
103
|
return inMemoryStats
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
|
-
} catch {
|
|
107
|
-
// If reading fails or file is corrupted,
|
|
106
|
+
} catch (err) {
|
|
107
|
+
// If reading fails or file is corrupted, preserve corrupted file for diagnostics
|
|
108
|
+
try {
|
|
109
|
+
if (existsSync(filePath)) {
|
|
110
|
+
const corruptBackup = `${filePath}.corrupt.${Date.now()}`
|
|
111
|
+
copyFileSync(filePath, corruptBackup)
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
/* ignore backup failure */
|
|
115
|
+
}
|
|
108
116
|
}
|
|
109
117
|
|
|
110
118
|
inMemoryStats = createEmptyStats()
|
|
111
119
|
return inMemoryStats
|
|
112
120
|
}
|
|
113
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Flush any debounced pending writes immediately to disk.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} [customPath]
|
|
126
|
+
*/
|
|
127
|
+
export function flushStats(customPath) {
|
|
128
|
+
if (saveTimer) {
|
|
129
|
+
clearTimeout(saveTimer)
|
|
130
|
+
saveTimer = null
|
|
131
|
+
}
|
|
132
|
+
saveStatsSync(customPath)
|
|
133
|
+
}
|
|
134
|
+
|
|
114
135
|
/**
|
|
115
136
|
* Synchronously write stats to disk using atomic rename.
|
|
116
137
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-clinebot",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.7",
|
|
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",
|