@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 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
@@ -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 quota for candidates if cached in memory
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
- for (const cand of candidates) {
155
- const cacheKey = buildUsageCacheKey(baseUrl, cand.value)
156
- const cached = usageCache.get(cacheKey)?.data
157
- const isExhausted = isAccountQuotaExhausted(cached)
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
- if (!isExhausted) {
160
- const pct = cached?.windows?.fiveHour?.percentUsed ?? 50
161
- if (pct < lowestUsagePct) {
162
- lowestUsagePct = pct
163
- bestCandidate = cand
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 either exhausted or uncached: pick next in round-robin
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
- busy,
2428
- handlePinAccount,
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
  }
@@ -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
+ }