@goodandready/dsh-clinebot 0.5.17 → 0.5.19

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,22 @@ 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.19] - 2026-10-07
9
+
10
+ ### Added
11
+ - **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.
12
+ - **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.
13
+
14
+ ## [0.5.18] - 2026-10-06
15
+
16
+ ### Fixed
17
+ - **Canonical Core CSS Theme Tokens Replacement (Issue #204)**: Replaced 6 non-existent legacy theme tokens across `src/client/` (`state-warning-primary`, `state-brand-primary`, `bg-hover`, `border`, `bg-layer-4`, `status-danger`) with canonical DSH 0.2.0-rc.2 core alias tokens (`state-warn-primary`, `brand-primary`, `interactive-bg-hover`, `border-l2`, `state-error-primary`), restoring color accents to warning badges, progress bars, warning banners, input focus states, button hover states, and critical quota warnings. Added automated test verifying all `--dsw-alias-*` tokens against canonical core allowlist.
18
+ - **Client Fetch Abort Timeout Protection (Issue #205)**: Implemented modular client helper `fetchWithTimeout(url, opts, ms = 30000)` using `AbortController` in `src/client/api.js`. Wrapped all 18 client-side `fetch` calls in `src/client/settings-page.js` to prevent infinite loading/busy button lockups on unresponsive networks or stalled endpoints, unlocking UI states after 30 seconds with informative timeout messaging.
19
+ - **Explicit Warning and Local Fallback for Missing ESLint (Issue #206)**: Updated `scripts/build-client.js` to look for local `node_modules/.bin/eslint` before fallback and emit an explicit stdout warning when eslint is not installed rather than silently suppressing ENOENT. Added `AbortController` to global identifier declarations.
20
+ - **Build Drift Verification via --check Flag (Issue #207)**: Added `--check` mode to `scripts/build-client.js` to verify in memory whether `lib/client.js` is in sync with `src/client/` source modules without rewriting the bundle. Integrated `--check` validation into `test/decomposition.test.js` to prevent uncommitted client bundle drift.
21
+ - **Strict Canonical Row Slot Key Registration (Issue #208)**: Removed redundant loop in `src/client/entry.js` registering inert legacy key `dsh-clinebot#dsh-clinebot`. Retained strictly canonical row seat registration under `ROW_CONFIG_KEY` (`@goodandready/dsh-clinebot#dsh-clinebot`).
22
+ - **Locale Dictionary Registration Error Logging (Issue #209)**: In `src/client/entry.js`, added explicit `console.warn` diagnostics reporting namespace and locale details upon `ctx.locale.register` failure instead of silently swallowing dictionary errors.
23
+
8
24
  ## [0.5.17] - 2026-10-05
9
25
 
10
26
  ### 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
@@ -44,6 +44,7 @@ const en = {
44
44
  'settings.loading': 'Loading ClineBot settings…',
45
45
  'settings.retry': 'Retry',
46
46
  'settings.unavailable': 'ClineBot settings unavailable (host namespace is not ready).',
47
+ 'api.timeout': 'Request timed out ({seconds}s)',
47
48
  'header.title': 'ClineBot (ClinePass) Provider',
48
49
  'header.sub': 'Connect models under your active ClinePass subscription. OpenAI API compatible, automated rolling limit tracking, and instant auto-registration in DSH.',
49
50
  'badge.online': 'Host online ({latency} ms)',
@@ -82,6 +83,9 @@ const en = {
82
83
  'accounts.cancel_btn': 'Cancel',
83
84
  'accounts.quota_used': '{pct}% used',
84
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)',
85
89
  'quota.title': '📊 ClinePass Quota & Rate Limits',
86
90
  'quota.desc': 'Official rolling window request limits from ClinePass',
87
91
  'quota.account': 'Account: {email} · Plan: {plan}',
@@ -197,6 +201,7 @@ const en = {
197
201
  'settings.loading': '正在加载 ClineBot 设置…',
198
202
  'settings.retry': '重试',
199
203
  'settings.unavailable': 'ClineBot 设置不可用(宿主命名空间尚未就绪)。',
204
+ 'api.timeout': '请求超时({seconds}秒)',
200
205
  'header.title': 'ClineBot (ClinePass) 模型服务商',
201
206
  'header.sub': '通过 ClinePass 订阅连接主流推理与编程模型。完全兼容 OpenAI 接口,自动追踪滚动限额并在 DSH 中即时生效。',
202
207
  'badge.online': '服务在线 ({latency} ms)',
@@ -235,6 +240,9 @@ const en = {
235
240
  'accounts.cancel_btn': '取消',
236
241
  'accounts.quota_used': '已用 {pct}%',
237
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)',
238
246
  'quota.title': '📊 ClinePass 用量配额与限额监控',
239
247
  'quota.desc': '来自 ClinePass 官方的实时滚动窗口用量限额',
240
248
  'quota.account': '账号:{email} · 套餐:{plan}',
@@ -406,15 +414,15 @@ function ensureCss() {
406
414
 
407
415
  .cb-badge{font-size:12px;padding:3px 10px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);display:inline-flex;align-items:center;gap:5px;font-weight:500}
408
416
  .cb-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent)}
409
- .cb-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 8%, transparent)}
417
+ .cb-badge-warn{border-color:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary);background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 8%, transparent)}
410
418
  .cb-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent)}
411
419
 
412
420
  .cb-input{height:36px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;width:100%;box-sizing:border-box}
413
- .cb-input:focus{outline:none;border-color:var(--dsw-alias-state-brand-primary)}
421
+ .cb-input:focus{outline:none;border-color:var(--dsw-alias-brand-primary)}
414
422
  .cb-input-group{display:flex;gap:8px;align-items:center}
415
423
 
416
424
  .cb-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:7px 14px;font-size:13px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-weight:500;display:inline-flex;align-items:center;justify-content:center;gap:6px;transition:all .15s ease}
417
- .cb-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-4, var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2))}
425
+ .cb-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2))}
418
426
  .cb-btn-primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:transparent}
419
427
  .cb-btn-primary:hover:not(:disabled){background:var(--dsw-alias-label-primary) !important;color:var(--dsw-alias-bg-layer-3) !important;opacity:0.88;visibility:visible !important}
420
428
  .cb-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent)}
@@ -426,8 +434,8 @@ function ensureCss() {
426
434
  .cb-progress-label{font-weight:500;white-space:nowrap}
427
435
  .cb-progress-meta, .cb-bar-meta{font-size:12px;color:var(--dsw-alias-label-secondary);white-space:nowrap;margin-left:auto}
428
436
  .cb-progress-track, .cb-bar-track{width:100%;height:8px;border-radius:999px;background:var(--dsw-alias-bg-layer-1);overflow:hidden;border:1px solid var(--dsw-alias-border-l2)}
429
- .cb-progress-bar-fill, .cb-bar-fill{height:100%;border-radius:999px;transition:width .3s;background:var(--dsw-alias-state-brand-primary, var(--dsw-alias-state-success-primary))}
430
- .cb-progress-bar-warn{height:100%;border-radius:999px;transition:width .3s;background:var(--dsw-alias-state-warning-primary)}
437
+ .cb-progress-bar-fill, .cb-bar-fill{height:100%;border-radius:999px;transition:width .3s;background:var(--dsw-alias-brand-primary, var(--dsw-alias-state-success-primary))}
438
+ .cb-progress-bar-warn{height:100%;border-radius:999px;transition:width .3s;background:var(--dsw-alias-state-warn-primary)}
431
439
  .cb-progress-bar-bad{height:100%;border-radius:999px;transition:width .3s;background:var(--dsw-alias-state-error-primary)}
432
440
 
433
441
  .cb-table{width:100%;border-collapse:collapse;margin-top:8px}
@@ -438,7 +446,7 @@ function ensureCss() {
438
446
  .cb-alert-ok{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent);color:var(--dsw-alias-state-success-primary);font-size:13px}
439
447
  .cb-alert-bad{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);font-size:13px}
440
448
  .cb-alert-err{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);font-size:13px}
441
- .cb-banner-warning{padding:12px 16px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 12%, transparent);border:1px solid var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);font-size:13px;display:flex;align-items:center;gap:10px;font-weight:500}
449
+ .cb-banner-warning{padding:12px 16px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent);border:1px solid var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary);font-size:13px;display:flex;align-items:center;gap:10px;font-weight:500}
442
450
  .cb-banner-exhausted{padding:12px 16px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);border:1px solid var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);font-size:13px;display:flex;align-items:center;gap:10px;font-weight:600}
443
451
  .cb-stat-box{padding:12px 14px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2);display:flex;flex-direction:column;gap:4px}
444
452
  .cb-stat-val{font-size:18px;font-weight:700;color:var(--dsw-alias-label-primary)}
@@ -448,6 +456,29 @@ function ensureCss() {
448
456
  document.head.appendChild(style)
449
457
  }
450
458
 
459
+ // --- src/client/api.js ---
460
+ async function fetchWithTimeout(url, opts = {}, ms = 30000) {
461
+ const controller = new AbortController()
462
+ const timer = setTimeout(() => {
463
+ controller.abort()
464
+ }, ms)
465
+ const signal = opts.signal || controller.signal
466
+ try {
467
+ const res = await fetch(url, { ...opts, signal })
468
+ return res
469
+ } catch (err) {
470
+ if (err && (err.name === 'AbortError' || controller.signal.aborted)) {
471
+ const sec = Math.round(ms / 1000)
472
+ const tFn = typeof opts.t === 'function' ? opts.t : null
473
+ const msg = tFn ? tFn('api.timeout', { seconds: sec }) : ('Request timed out (' + sec + 's)')
474
+ throw new Error(msg)
475
+ }
476
+ throw err
477
+ } finally {
478
+ clearTimeout(timer)
479
+ }
480
+ }
481
+
451
482
  // --- src/client/error-boundary.js ---
452
483
  function createErrorBoundary() {
453
484
  try {
@@ -549,7 +580,7 @@ function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
549
580
  gap: '8px',
550
581
  padding: '12px 14px',
551
582
  borderRadius: '8px',
552
- border: isBad ? '1px solid var(--dsw-alias-status-danger)' : '1px solid var(--dsw-alias-border-l2)',
583
+ border: isBad ? '1px solid var(--dsw-alias-state-error-primary)' : '1px solid var(--dsw-alias-border-l2)',
553
584
  background: 'var(--dsw-alias-bg-layer-2)',
554
585
  },
555
586
  },
@@ -569,7 +600,7 @@ function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
569
600
  'span',
570
601
  { className: 'cb-progress-label', style: { fontWeight: 500, whiteSpace: 'nowrap' } },
571
602
  label,
572
- isBad ? React.createElement('span', { style: { marginLeft: '6px', fontSize: '11px', color: 'var(--dsw-alias-status-danger)' } }, '⚠️ <10%') : null
603
+ isBad ? React.createElement('span', { style: { marginLeft: '6px', fontSize: '11px', color: 'var(--dsw-alias-state-error-primary)' } }, '⚠️ <10%') : null
573
604
  ),
574
605
  React.createElement(
575
606
  'span',
@@ -577,7 +608,7 @@ function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
577
608
  className: 'cb-progress-meta cb-bar-meta',
578
609
  style: {
579
610
  fontSize: '12px',
580
- color: isBad ? 'var(--dsw-alias-status-danger)' : 'var(--dsw-alias-label-secondary)',
611
+ color: isBad ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-label-secondary)',
581
612
  whiteSpace: 'nowrap',
582
613
  marginLeft: 'auto',
583
614
  },
@@ -764,7 +795,7 @@ function KeySection({ keyPresent, apiKeyInput, setApiKeyInput, showKey, setShowK
764
795
  href: 'https://app.cline.bot/settings/api-keys',
765
796
  target: '_blank',
766
797
  rel: 'noreferrer',
767
- style: { color: 'var(--dsw-alias-state-brand-primary)' },
798
+ style: { color: 'var(--dsw-alias-brand-primary)' },
768
799
  },
769
800
  t('key.get_key')
770
801
  )
@@ -773,7 +804,7 @@ function KeySection({ keyPresent, apiKeyInput, setApiKeyInput, showKey, setShowK
773
804
  }
774
805
 
775
806
  // --- src/client/components/accounts-section.js ---
776
- function AccountsSection({ status, busy, handlePinAccount, handleAddAccount, handleDeleteAccount, t }) {
807
+ function AccountsSection({ status, busy, handlePinAccount, handleAddAccount, handleDeleteAccount, accountMode, handleUpdateAccountMode, t }) {
777
808
  if (!status.accounts || !status.accounts.length) return null
778
809
 
779
810
  const [showAdd, setShowAdd] = React.useState(false)
@@ -843,6 +874,72 @@ function AccountsSection({ status, busy, handlePinAccount, handleAddAccount, han
843
874
  : null
844
875
  ),
845
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
+ ),
846
943
 
847
944
  showAdd
848
945
  ? React.createElement(
@@ -850,8 +947,8 @@ function AccountsSection({ status, busy, handlePinAccount, handleAddAccount, han
850
947
  {
851
948
  onSubmit: onSubmitAdd,
852
949
  style: {
853
- background: 'var(--dsw-alias-bg-hover, color-mix(in srgb, currentColor 4%, transparent))',
854
- border: '1px solid var(--dsw-alias-border, color-mix(in srgb, currentColor 12%, transparent))',
950
+ background: 'var(--dsw-alias-interactive-bg-hover, color-mix(in srgb, currentColor 4%, transparent))',
951
+ border: '1px solid var(--dsw-alias-border-l2, color-mix(in srgb, currentColor 12%, transparent))',
855
952
  borderRadius: '8px',
856
953
  padding: '12px',
857
954
  marginBottom: '14px',
@@ -1522,7 +1619,7 @@ function ModelsSection({
1522
1619
  display: 'flex',
1523
1620
  flexDirection: 'column',
1524
1621
  gap: '6px',
1525
- background: 'var(--dsw-alias-bg-hover, color-mix(in srgb, currentColor 4%, transparent))',
1622
+ background: 'var(--dsw-alias-interactive-bg-hover, color-mix(in srgb, currentColor 4%, transparent))',
1526
1623
  padding: '6px',
1527
1624
  borderRadius: '6px',
1528
1625
  border: '1px solid var(--dsw-alias-border-l2)',
@@ -1985,8 +2082,8 @@ function SettingsPage(props) {
1985
2082
  const load = React.useCallback(async () => {
1986
2083
  setErr('')
1987
2084
  const [res, statsRes] = await Promise.all([
1988
- fetch(`${ROUTE_PREFIX}/status`, { cache: 'no-store' }),
1989
- fetch(`${ROUTE_PREFIX}/stats`, { cache: 'no-store' }).catch(() => null),
2085
+ fetchWithTimeout(`${ROUTE_PREFIX}/status`, { cache: 'no-store' }),
2086
+ fetchWithTimeout(`${ROUTE_PREFIX}/stats`, { cache: 'no-store' }).catch(() => null),
1990
2087
  ])
1991
2088
  const data = await res.json().catch(() => ({}))
1992
2089
  if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
@@ -2001,7 +2098,7 @@ function SettingsPage(props) {
2001
2098
  const checkUpdate = React.useCallback(async () => {
2002
2099
  setUpdateState((s) => ({ ...s, checking: true, status: 'checking', error: '' }))
2003
2100
  try {
2004
- const res = await fetch(`${ROUTE_PREFIX}/update`)
2101
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/update`)
2005
2102
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
2006
2103
  const data = await res.json().catch(() => ({}))
2007
2104
  if (data.latestCheckFailed) {
@@ -2045,7 +2142,7 @@ function SettingsPage(props) {
2045
2142
  if (updateState.updating) return
2046
2143
  setUpdateState((s) => ({ ...s, updating: true, error: '', notice: '' }))
2047
2144
  try {
2048
- const res = await fetch(`${ROUTE_PREFIX}/update`, {
2145
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/update`, {
2049
2146
  method: 'POST',
2050
2147
  headers: { 'x-dsh-plugin-update': '1' },
2051
2148
  })
@@ -2089,7 +2186,7 @@ function SettingsPage(props) {
2089
2186
  const keyVal = String(apiKeyInput || '').trim()
2090
2187
  if (!keyVal) { setErr(t('key.empty_err')); return }
2091
2188
  await performAction('save-key', async () => {
2092
- const res = await fetch(`${ROUTE_PREFIX}/save-key`, {
2189
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/save-key`, {
2093
2190
  method: 'POST', headers: { 'Content-Type': 'application/json' },
2094
2191
  body: JSON.stringify({ apiKey: keyVal, apiKeyEnv: draft?.apiKeyEnv }),
2095
2192
  })
@@ -2103,7 +2200,7 @@ function SettingsPage(props) {
2103
2200
 
2104
2201
  async function handleRefreshQuota() {
2105
2202
  await performAction('refresh-quota', async () => {
2106
- const res = await fetch(`${ROUTE_PREFIX}/usage`)
2203
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/usage`)
2107
2204
  const data = await res.json().catch(() => ({}))
2108
2205
  if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
2109
2206
  setStatus((prev) => (prev ? { ...prev, usage: data } : prev))
@@ -2113,7 +2210,7 @@ function SettingsPage(props) {
2113
2210
 
2114
2211
  async function handleUpdateModelContext(payload) {
2115
2212
  await performAction('update-context', async () => {
2116
- const res = await fetch(`${ROUTE_PREFIX}/models/context`, {
2213
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/models/context`, {
2117
2214
  method: 'POST', headers: { 'Content-Type': 'application/json' },
2118
2215
  body: JSON.stringify(payload),
2119
2216
  })
@@ -2126,7 +2223,7 @@ function SettingsPage(props) {
2126
2223
 
2127
2224
  async function handleSyncPlanModels() {
2128
2225
  await performAction('sync-models', async () => {
2129
- const res = await fetch(`${ROUTE_PREFIX}/models/sync`, { method: 'POST' })
2226
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/models/sync`, { method: 'POST' })
2130
2227
  const data = await res.json().catch(() => ({}))
2131
2228
  if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
2132
2229
  setMsg(t('models.synced_msg', { total: data.totalModelsCount, discovered: data.discoveredCount }))
@@ -2136,7 +2233,7 @@ function SettingsPage(props) {
2136
2233
 
2137
2234
  async function handleRegister() {
2138
2235
  await performAction('register', async () => {
2139
- const res = await fetch(`${ROUTE_PREFIX}/register`, { method: 'POST' })
2236
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/register`, { method: 'POST' })
2140
2237
  const data = await res.json().catch(() => ({}))
2141
2238
  if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
2142
2239
  const count = status?.availableModels?.filter((m) => !(draft?.disabledModels || []).includes(m.id)).length || 0
@@ -2147,7 +2244,7 @@ function SettingsPage(props) {
2147
2244
 
2148
2245
  async function handleUnregister() {
2149
2246
  await performAction('unregister', async () => {
2150
- const res = await fetch(`${ROUTE_PREFIX}/unregister`, { method: 'POST' })
2247
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/unregister`, { method: 'POST' })
2151
2248
  const data = await res.json().catch(() => ({}))
2152
2249
  if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
2153
2250
  setMsg(t('diag.unregistered_msg'))
@@ -2158,7 +2255,7 @@ function SettingsPage(props) {
2158
2255
  async function handleSmoke() {
2159
2256
  setSmokeResult(null)
2160
2257
  await performAction('smoke', async () => {
2161
- const res = await fetch(`${ROUTE_PREFIX}/smoke`, {
2258
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/smoke`, {
2162
2259
  method: 'POST',
2163
2260
  headers: { 'Content-Type': 'application/json' },
2164
2261
  body: JSON.stringify({ model: draft?.defaultModel }),
@@ -2182,7 +2279,7 @@ function SettingsPage(props) {
2182
2279
  verifyTimerRef.current = setTimeout(async () => {
2183
2280
  setVerifyStatus({ state: 'verifying', email: '', plan: '', error: '' })
2184
2281
  try {
2185
- const res = await fetch(`${ROUTE_PREFIX}/key/verify`, {
2282
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/key/verify`, {
2186
2283
  method: 'POST', headers: { 'Content-Type': 'application/json' },
2187
2284
  body: JSON.stringify({ key: raw }),
2188
2285
  })
@@ -2207,7 +2304,7 @@ function SettingsPage(props) {
2207
2304
 
2208
2305
  async function handlePinAccount(accountEnv) {
2209
2306
  await performAction('pin-account', async () => {
2210
- const res = await fetch(`${ROUTE_PREFIX}/accounts/active`, {
2307
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/accounts/active`, {
2211
2308
  method: 'POST', headers: { 'Content-Type': 'application/json' },
2212
2309
  body: JSON.stringify({ account: accountEnv }),
2213
2310
  })
@@ -2219,7 +2316,7 @@ function SettingsPage(props) {
2219
2316
 
2220
2317
  async function handleAddAccount({ label, apiKeyEnv, apiKey }) {
2221
2318
  await performAction('add-account', async () => {
2222
- const res = await fetch(`${ROUTE_PREFIX}/accounts`, {
2319
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/accounts`, {
2223
2320
  method: 'POST', headers: { 'Content-Type': 'application/json' },
2224
2321
  body: JSON.stringify({ label, apiKeyEnv, apiKey }),
2225
2322
  })
@@ -2232,7 +2329,7 @@ function SettingsPage(props) {
2232
2329
 
2233
2330
  async function handleDeleteAccount(accountEnv, deleteSecret = true) {
2234
2331
  await performAction('delete-account', async () => {
2235
- const res = await fetch(`${ROUTE_PREFIX}/accounts/delete`, {
2332
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/accounts/delete`, {
2236
2333
  method: 'POST', headers: { 'Content-Type': 'application/json' },
2237
2334
  body: JSON.stringify({ apiKeyEnv: accountEnv, deleteSecret }),
2238
2335
  })
@@ -2245,7 +2342,7 @@ function SettingsPage(props) {
2245
2342
  async function handleConfigPatch(patch) {
2246
2343
  setDraft((d) => ({ ...(d || {}), ...(patch || {}) }))
2247
2344
  await performAction('patch-config', async () => {
2248
- const res = await fetch(`${ROUTE_PREFIX}/config`, {
2345
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/config`, {
2249
2346
  method: 'PUT', headers: { 'Content-Type': 'application/json' },
2250
2347
  body: JSON.stringify({ config: patch }),
2251
2348
  })
@@ -2257,7 +2354,7 @@ function SettingsPage(props) {
2257
2354
 
2258
2355
  async function handleResetStats() {
2259
2356
  await performAction('reset-stats', async () => {
2260
- const res = await fetch(`${ROUTE_PREFIX}/stats/reset`, { method: 'POST' })
2357
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/stats/reset`, { method: 'POST' })
2261
2358
  const data = await res.json().catch(() => ({}))
2262
2359
  if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
2263
2360
  await load()
@@ -2272,7 +2369,7 @@ function SettingsPage(props) {
2272
2369
  if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
2273
2370
  debounceTimerRef.current = setTimeout(async () => {
2274
2371
  try {
2275
- const res = await fetch(`${ROUTE_PREFIX}/models/toggle`, {
2372
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/models/toggle`, {
2276
2373
  method: 'POST',
2277
2374
  headers: { 'Content-Type': 'application/json' },
2278
2375
  body: JSON.stringify({ disabledModels: nextDisabled }),
@@ -2398,11 +2495,9 @@ function SettingsPage(props) {
2398
2495
 
2399
2496
  // Accounts Pool Card
2400
2497
  React.createElement(AccountsSection, {
2401
- status,
2402
- busy,
2403
- handlePinAccount,
2404
- handleAddAccount,
2405
- handleDeleteAccount,
2498
+ status, busy, handlePinAccount, handleAddAccount, handleDeleteAccount,
2499
+ accountMode: draft?.accountMode || status?.config?.accountMode || 'least_used',
2500
+ handleUpdateAccountMode: (mode) => handleConfigPatch({ accountMode: mode }),
2406
2501
  t,
2407
2502
  }),
2408
2503
 
@@ -2527,7 +2622,8 @@ function apply(ctx) {
2527
2622
  const addLocale = (locale, dictionary) => {
2528
2623
  try {
2529
2624
  return ctx.locale.register(NS, locale, dictionary)
2530
- } catch (_) {
2625
+ } catch (err) {
2626
+ console.warn(`[dsh-clinebot] Failed to register locale dictionary for ns "${NS}", locale "${locale}":`, err)
2531
2627
  return () => {}
2532
2628
  }
2533
2629
  }
@@ -2597,19 +2693,18 @@ function apply(ctx) {
2597
2693
  // The core keys the seat as `<bundle name>#<row id>`; `bundle.name` may be the
2598
2694
  // package name or the short bundle name depending on the manager's view, so both
2599
2695
  // spellings are registered — the unused one is inert.
2600
- for (const key of [ROW_CONFIG_KEY, ROW_ID + '#' + ROW_ID]) {
2601
- registerSlotWhenReady('plugins.row.config', () =>
2602
- ctx.slots.register(
2603
- {
2604
- name: 'plugins.row.config',
2605
- key,
2606
- locale: NS,
2607
- inject: () => ({ ctx }),
2608
- },
2609
- (props) => React.createElement(ErrorBoundary, { t: props && props.t }, React.createElement(PluginCard, { ...props, ctx: (props && props.ctx) || ctx }))
2610
- )
2696
+ // Row seat: key = ROW_CONFIG_KEY ('@goodandready/dsh-clinebot#dsh-clinebot').
2697
+ registerSlotWhenReady('plugins.row.config', () =>
2698
+ ctx.slots.register(
2699
+ {
2700
+ name: 'plugins.row.config',
2701
+ key: ROW_CONFIG_KEY,
2702
+ locale: NS,
2703
+ inject: () => ({ ctx }),
2704
+ },
2705
+ (props) => React.createElement(ErrorBoundary, { t: props && props.t }, React.createElement(PluginCard, { ...props, ctx: (props && props.ctx) || ctx }))
2611
2706
  )
2612
- }
2707
+ )
2613
2708
 
2614
2709
  registerSlotWhenReady('settings.plugin.item', () =>
2615
2710
  ctx.slots.register(
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.19'
18
+ try {
19
+ packageVersion = require('../package.json').version || '0.5.19'
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
+ }