@goodandready/dsh-clinebot 0.5.18 → 0.5.20
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 +11 -0
- package/lib/account-pool.js +14 -11
- package/lib/client.js +76 -6
- package/lib/config.js +3 -0
- package/lib/proxy-token.js +78 -0
- package/lib/routes/proxy.js +408 -391
- package/lib/session-router.js +43 -6
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,17 @@ All notable changes to `@goodandready/dsh-clinebot` will be documented in this f
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.5.20] - 2026-10-11
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **Local ESLint Binary Execution and devDependencies Enforcement (Issue #214)**: Added `eslint` (`^9.39.5`) to `devDependencies` in `package.json` and committed `package-lock.json`. Updated `scripts/build-client.js` to strictly enforce local binary execution (`node_modules/.bin/eslint`) and immediately abort with exit code 1 if `eslint` is missing, preventing silent bypass of `no-undef` validation on fresh clones and CI environments. Added automated decomposition test in `test/decomposition.test.js` validating local `eslint` presence and zero `no-undef` violations.
|
|
12
|
+
|
|
13
|
+
## [0.5.19] - 2026-10-07
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- **Multi-Account Round-Robin Pool Mode (Issue #211)**: Added `accountMode` configuration setting (`least_used` | `round_robin`, default `least_used`) in schema, public config, and DSH settings API. Implemented sequential round-robin candidate rotation in `session-router.js` (`getNextRoundRobinAccount`) and active account rotation in `account-pool.js` (`rotateToNextAccount`), automatically skipping accounts in active cooldown. Added UI strategy selector button group in `AccountsSection` with canonical DSH core tokens and full English/Chinese localization.
|
|
17
|
+
- **Pre-First-Token Failover Contract (Issue #212)**: In transparent loopback proxy (`/dsh-clinebot/v1/chat/completions`), response headers (`HTTP 200`, `Content-Type: text/event-stream`, `flushHeaders`) are now committed to the client only after receiving the first valid token chunk. Any upstream failure prior to the first token (HTTP 429, 402, 5xx, 401/403, TTFT timeout, connection reset, or early JSON error payload in 200 OK stream) triggers immediate cooldown and seamless failover to the next candidate account in the pool without exposing errors to the client. Non-streaming requests similarly enforce deadline and failover on body read errors before committing client response.
|
|
18
|
+
|
|
8
19
|
## [0.5.18] - 2026-10-06
|
|
9
20
|
|
|
10
21
|
### Fixed
|
package/lib/account-pool.js
CHANGED
|
@@ -147,25 +147,28 @@ export async function rotateToNextAccount(ctx, cfg, reason = 'rate_limit', setti
|
|
|
147
147
|
return { rotated: false, reason, message: 'No alternative accounts configured' }
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
// Assess
|
|
150
|
+
// Assess candidate selection according to accountMode
|
|
151
|
+
const mode = cfg?.accountMode === 'round_robin' ? 'round_robin' : 'least_used'
|
|
151
152
|
let bestCandidate = null
|
|
152
153
|
let lowestUsagePct = Infinity
|
|
153
154
|
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
155
|
+
if (mode !== 'round_robin') {
|
|
156
|
+
for (const cand of candidates) {
|
|
157
|
+
const cacheKey = buildUsageCacheKey(baseUrl, cand.value)
|
|
158
|
+
const cached = usageCache.get(cacheKey)?.data
|
|
159
|
+
const isExhausted = isAccountQuotaExhausted(cached)
|
|
158
160
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
161
|
+
if (!isExhausted) {
|
|
162
|
+
const pct = cached?.windows?.fiveHour?.percentUsed ?? 50
|
|
163
|
+
if (pct < lowestUsagePct) {
|
|
164
|
+
lowestUsagePct = pct
|
|
165
|
+
bestCandidate = cand
|
|
166
|
+
}
|
|
164
167
|
}
|
|
165
168
|
}
|
|
166
169
|
}
|
|
167
170
|
|
|
168
|
-
// Fallback if all candidates are
|
|
171
|
+
// Fallback if all candidates are exhausted/uncached or mode is round-robin: pick next candidate
|
|
169
172
|
const nextAcc = bestCandidate || candidates[currentIndex % candidates.length] || candidates[0]
|
|
170
173
|
|
|
171
174
|
const prevActive = String(cfg.activeAccount || cfg.apiKeyEnv || "").trim()
|
package/lib/client.js
CHANGED
|
@@ -83,6 +83,9 @@ const en = {
|
|
|
83
83
|
'accounts.cancel_btn': 'Cancel',
|
|
84
84
|
'accounts.quota_used': '{pct}% used',
|
|
85
85
|
'accounts.last_failover': 'Last failover: {from} → {to} ({reason})',
|
|
86
|
+
'accounts.mode_label': 'Pool Strategy',
|
|
87
|
+
'accounts.mode_least_used': 'Least Used (Quota)',
|
|
88
|
+
'accounts.mode_round_robin': 'Round Robin (Sequential)',
|
|
86
89
|
'quota.title': '📊 ClinePass Quota & Rate Limits',
|
|
87
90
|
'quota.desc': 'Official rolling window request limits from ClinePass',
|
|
88
91
|
'quota.account': 'Account: {email} · Plan: {plan}',
|
|
@@ -237,6 +240,9 @@ const en = {
|
|
|
237
240
|
'accounts.cancel_btn': '取消',
|
|
238
241
|
'accounts.quota_used': '已用 {pct}%',
|
|
239
242
|
'accounts.last_failover': '最近故障转移:{from} → {to} ({reason})',
|
|
243
|
+
'accounts.mode_label': '调度策略',
|
|
244
|
+
'accounts.mode_least_used': '低占用优先 (Least Used)',
|
|
245
|
+
'accounts.mode_round_robin': '顺序轮询 (Round Robin)',
|
|
240
246
|
'quota.title': '📊 ClinePass 用量配额与限额监控',
|
|
241
247
|
'quota.desc': '来自 ClinePass 官方的实时滚动窗口用量限额',
|
|
242
248
|
'quota.account': '账号:{email} · 套餐:{plan}',
|
|
@@ -798,7 +804,7 @@ function KeySection({ keyPresent, apiKeyInput, setApiKeyInput, showKey, setShowK
|
|
|
798
804
|
}
|
|
799
805
|
|
|
800
806
|
// --- src/client/components/accounts-section.js ---
|
|
801
|
-
function AccountsSection({ status, busy, handlePinAccount, handleAddAccount, handleDeleteAccount, t }) {
|
|
807
|
+
function AccountsSection({ status, busy, handlePinAccount, handleAddAccount, handleDeleteAccount, accountMode, handleUpdateAccountMode, t }) {
|
|
802
808
|
if (!status.accounts || !status.accounts.length) return null
|
|
803
809
|
|
|
804
810
|
const [showAdd, setShowAdd] = React.useState(false)
|
|
@@ -868,6 +874,72 @@ function AccountsSection({ status, busy, handlePinAccount, handleAddAccount, han
|
|
|
868
874
|
: null
|
|
869
875
|
),
|
|
870
876
|
React.createElement('div', { className: 'cb-section-desc' }, t('accounts.desc')),
|
|
877
|
+
React.createElement(
|
|
878
|
+
'div',
|
|
879
|
+
{
|
|
880
|
+
style: {
|
|
881
|
+
display: 'flex',
|
|
882
|
+
alignItems: 'center',
|
|
883
|
+
gap: '8px',
|
|
884
|
+
margin: '8px 0 12px 0',
|
|
885
|
+
fontSize: '12px',
|
|
886
|
+
color: 'var(--dsw-alias-label-secondary, #888)',
|
|
887
|
+
},
|
|
888
|
+
},
|
|
889
|
+
React.createElement('span', { style: { fontWeight: 500 } }, t('accounts.mode_label') + ':'),
|
|
890
|
+
React.createElement(
|
|
891
|
+
'div',
|
|
892
|
+
{
|
|
893
|
+
style: {
|
|
894
|
+
display: 'inline-flex',
|
|
895
|
+
borderRadius: '6px',
|
|
896
|
+
border: '1px solid var(--dsw-alias-border-l2, color-mix(in srgb, currentColor 12%, transparent))',
|
|
897
|
+
overflow: 'hidden',
|
|
898
|
+
},
|
|
899
|
+
},
|
|
900
|
+
React.createElement(
|
|
901
|
+
'button',
|
|
902
|
+
{
|
|
903
|
+
type: 'button',
|
|
904
|
+
className: 'cb-btn',
|
|
905
|
+
style: {
|
|
906
|
+
borderRadius: 0,
|
|
907
|
+
fontSize: '11px',
|
|
908
|
+
padding: '3px 9px',
|
|
909
|
+
border: 'none',
|
|
910
|
+
background: (accountMode || 'least_used') === 'least_used'
|
|
911
|
+
? 'var(--dsw-alias-interactive-bg-active, color-mix(in srgb, currentColor 14%, transparent))'
|
|
912
|
+
: 'transparent',
|
|
913
|
+
fontWeight: (accountMode || 'least_used') === 'least_used' ? 600 : 400,
|
|
914
|
+
},
|
|
915
|
+
onClick: () => typeof handleUpdateAccountMode === 'function' && handleUpdateAccountMode('least_used'),
|
|
916
|
+
disabled: !!busy,
|
|
917
|
+
},
|
|
918
|
+
t('accounts.mode_least_used')
|
|
919
|
+
),
|
|
920
|
+
React.createElement(
|
|
921
|
+
'button',
|
|
922
|
+
{
|
|
923
|
+
type: 'button',
|
|
924
|
+
className: 'cb-btn',
|
|
925
|
+
style: {
|
|
926
|
+
borderRadius: 0,
|
|
927
|
+
fontSize: '11px',
|
|
928
|
+
padding: '3px 9px',
|
|
929
|
+
border: 'none',
|
|
930
|
+
borderLeft: '1px solid var(--dsw-alias-border-l2, color-mix(in srgb, currentColor 12%, transparent))',
|
|
931
|
+
background: accountMode === 'round_robin'
|
|
932
|
+
? 'var(--dsw-alias-interactive-bg-active, color-mix(in srgb, currentColor 14%, transparent))'
|
|
933
|
+
: 'transparent',
|
|
934
|
+
fontWeight: accountMode === 'round_robin' ? 600 : 400,
|
|
935
|
+
},
|
|
936
|
+
onClick: () => typeof handleUpdateAccountMode === 'function' && handleUpdateAccountMode('round_robin'),
|
|
937
|
+
disabled: !!busy,
|
|
938
|
+
},
|
|
939
|
+
t('accounts.mode_round_robin')
|
|
940
|
+
)
|
|
941
|
+
)
|
|
942
|
+
),
|
|
871
943
|
|
|
872
944
|
showAdd
|
|
873
945
|
? React.createElement(
|
|
@@ -2423,11 +2495,9 @@ function SettingsPage(props) {
|
|
|
2423
2495
|
|
|
2424
2496
|
// Accounts Pool Card
|
|
2425
2497
|
React.createElement(AccountsSection, {
|
|
2426
|
-
status,
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
handleAddAccount,
|
|
2430
|
-
handleDeleteAccount,
|
|
2498
|
+
status, busy, handlePinAccount, handleAddAccount, handleDeleteAccount,
|
|
2499
|
+
accountMode: draft?.accountMode || status?.config?.accountMode || 'least_used',
|
|
2500
|
+
handleUpdateAccountMode: (mode) => handleConfigPatch({ accountMode: mode }),
|
|
2431
2501
|
t,
|
|
2432
2502
|
}),
|
|
2433
2503
|
|
package/lib/config.js
CHANGED
|
@@ -88,6 +88,8 @@ export const Config = z.object({
|
|
|
88
88
|
.description('Additional accounts for multi-account failover and rate limit rotation.').volatile(),
|
|
89
89
|
activeAccount: z.string().default('')
|
|
90
90
|
.description('Manually pinned active account envName or empty for auto/default.').volatile(),
|
|
91
|
+
accountMode: z.union(['least_used', 'round_robin']).default('least_used')
|
|
92
|
+
.description('Account selection strategy: least_used (quota-aware) or round_robin (sequential rotation).').volatile(),
|
|
91
93
|
})
|
|
92
94
|
|
|
93
95
|
export function isVolatileRef(value) {
|
|
@@ -177,5 +179,6 @@ export function publicConfig(cfg) {
|
|
|
177
179
|
modelsCachePath: String(cfg?.modelsCachePath || '~/.dsh/clinebot-models-cache.json'),
|
|
178
180
|
accounts: Array.isArray(cfg?.accounts) ? cfg.accounts : [],
|
|
179
181
|
activeAccount: String(cfg?.activeAccount || ''),
|
|
182
|
+
accountMode: cfg?.accountMode === 'round_robin' ? 'round_robin' : 'least_used',
|
|
180
183
|
}
|
|
181
184
|
}
|
package/lib/proxy-token.js
CHANGED
|
@@ -6,11 +6,23 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import crypto from 'node:crypto'
|
|
9
|
+
import { createRequire } from 'node:module'
|
|
10
|
+
import { writeJson } from './http.js'
|
|
9
11
|
|
|
10
12
|
export const LOCAL_PROXY_KEY_ENV = 'CLINEBOT_LOCAL_PROXY_TOKEN'
|
|
11
13
|
|
|
12
14
|
let cachedToken = null
|
|
13
15
|
|
|
16
|
+
const require = createRequire(import.meta.url)
|
|
17
|
+
let packageVersion = '0.5.20'
|
|
18
|
+
try {
|
|
19
|
+
packageVersion = require('../package.json').version || '0.5.20'
|
|
20
|
+
} catch (err) {
|
|
21
|
+
/* fallback to default version if manifest is unreadable */
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const USER_AGENT = `@goodandready/dsh-clinebot/${packageVersion}`
|
|
25
|
+
|
|
14
26
|
/**
|
|
15
27
|
* Get or generate the local proxy bearer token.
|
|
16
28
|
*
|
|
@@ -40,3 +52,69 @@ export function setLocalProxyToken(token) {
|
|
|
40
52
|
delete process.env[LOCAL_PROXY_KEY_ENV]
|
|
41
53
|
}
|
|
42
54
|
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Reject cross-site and browser requests to proxy endpoints.
|
|
58
|
+
*/
|
|
59
|
+
export function hasDisallowedBrowserOrigin(req) {
|
|
60
|
+
const secFetchSite = req.headers?.['sec-fetch-site']
|
|
61
|
+
return secFetchSite === 'cross-site' || secFetchSite === 'same-site' || Boolean(req.headers?.['origin'])
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Verify whether incoming HTTP request is authorized to access proxy endpoints.
|
|
66
|
+
*/
|
|
67
|
+
export function isAuthorizedProxyRequest(req, accountPool = []) {
|
|
68
|
+
const auth = req.headers?.['authorization'] || ''
|
|
69
|
+
const match = auth.match(/^Bearer\s+(.+)$/i)
|
|
70
|
+
if (!match) return false
|
|
71
|
+
const token = match[1].trim()
|
|
72
|
+
if (!token) return false
|
|
73
|
+
const localToken = getLocalProxyToken()
|
|
74
|
+
if (localToken && token === localToken) return true
|
|
75
|
+
return Array.isArray(accountPool) && accountPool.some((acc) => acc.value && acc.value === token)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Write appropriate terminal JSON error response when retries are exhausted.
|
|
80
|
+
*/
|
|
81
|
+
export function sendProxyTerminalError(res, { hadTimeout, is429Exhausted, lastError, lastRetryAfter }) {
|
|
82
|
+
if (hadTimeout) {
|
|
83
|
+
const code = lastError?.code || (String(lastError?.message).toLowerCase().includes('body read') ? 'body_read_timeout' : 'upstream_timeout')
|
|
84
|
+
return writeJson(res, 504, {
|
|
85
|
+
error: {
|
|
86
|
+
message: 'Gateway Timeout: upstream provider timed out across all available accounts',
|
|
87
|
+
type: 'gateway_timeout',
|
|
88
|
+
code,
|
|
89
|
+
details: lastError?.message || null,
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (is429Exhausted) {
|
|
95
|
+
if (lastRetryAfter) {
|
|
96
|
+
res.setHeader('Retry-After', lastRetryAfter)
|
|
97
|
+
}
|
|
98
|
+
return writeJson(res, 429, {
|
|
99
|
+
error: {
|
|
100
|
+
message: 'All configured accounts in pool exceeded rate limits or quota',
|
|
101
|
+
type: 'rate_limit_exceeded',
|
|
102
|
+
code: 'rate_limit_exceeded',
|
|
103
|
+
details: lastError?.message || null,
|
|
104
|
+
},
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const status = typeof lastError?.status === 'number' && lastError.status >= 500 && lastError.status <= 599
|
|
109
|
+
? lastError.status
|
|
110
|
+
: 502
|
|
111
|
+
const code = lastError?.code || 'upstream_unavailable'
|
|
112
|
+
return writeJson(res, status, {
|
|
113
|
+
error: {
|
|
114
|
+
message: `Bad Gateway: upstream network error or unavailable: ${lastError?.message || 'unknown error'}`,
|
|
115
|
+
type: 'bad_gateway',
|
|
116
|
+
code,
|
|
117
|
+
details: lastError?.message || null,
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
}
|