@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 +16 -0
- package/lib/account-pool.js +14 -11
- package/lib/client.js +145 -50
- 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 +1 -1
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
|
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
|
@@ -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-
|
|
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-
|
|
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-
|
|
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-
|
|
430
|
-
.cb-progress-bar-warn{height:100%;border-radius:999px;transition:width .3s;background:var(--dsw-alias-state-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
1989
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
2403
|
-
|
|
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
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
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
|
}
|
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.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
|
+
}
|