@goodandready/dsh-clinebot 0.5.7 → 0.5.9

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/lib/client.js CHANGED
@@ -24,6 +24,19 @@ function readConfigForms(ctx) {
24
24
  try { return ctx.get('configForms') || undefined } catch { return undefined }
25
25
  }
26
26
 
27
+ function useConfigFormsSnapshot(ctx) {
28
+ const scope = React.useMemo(() => {
29
+ try { return readConfigForms(ctx)?.get?.(NS) || undefined } catch { return undefined }
30
+ }, [ctx])
31
+ const subscribe = React.useMemo(() => (cb) => {
32
+ try { return scope?.subscribe ? (scope.subscribe(cb) || (() => {})) : () => {} } catch { return () => {} }
33
+ }, [scope])
34
+ const getSnapshot = React.useCallback(() => {
35
+ try { return scope?.getSnapshot?.() || SNAPSHOT_READY } catch { return SNAPSHOT_READY }
36
+ }, [scope])
37
+ return React.useSyncExternalStore(subscribe, getSnapshot, () => SNAPSHOT_READY)?.status || 'loading'
38
+ }
39
+
27
40
  // --- src/client/locales.js ---
28
41
  const en = {
29
42
  title: 'ClineBot',
@@ -79,6 +92,27 @@ const en = {
79
92
  'quota.window_weekly': '📅 Weekly Window',
80
93
  'quota.used': '{pct}% used',
81
94
  'quota.reset_at': 'Reset: {time}',
95
+ 'quota.window_monthly': '📅 Monthly Window',
96
+ 'quota.countdown': 'Resets in {time}',
97
+ 'quota.warning_low': 'Quota is below 10%. Requests may be throttled soon.',
98
+ 'quota.exhausted_5h': '5-hour rolling limit is almost exhausted ({percentUsed}%). New requests may be rejected until quota reset.',
99
+ 'quota.warning_5h': 'Low quota warning: {percentUsed}% of 5-hour limit consumed (<10% remaining).',
100
+ 'quota.warning_weekly': 'Low weekly quota warning: {percentUsed}% consumed ({remainingPercent}% remaining).',
101
+ 'quota.warning_monthly': 'Low monthly quota warning: {percentUsed}% consumed ({remainingPercent}% remaining).',
102
+ 'models.reasoning_tag': '🧠 Reasoning',
103
+ 'models.effort_low': 'Low',
104
+ 'models.effort_medium': 'Medium',
105
+ 'models.effort_high': 'High',
106
+ 'models.effort_max': 'Max',
107
+ 'models.caps_vision': 'Vision',
108
+ 'models.caps_general': 'General',
109
+ 'models.caps_coding': 'Coding',
110
+ 'diag.smoke_result': '✅ Latency: {latency} ms | Model: {model}\nResponse: {preview}',
111
+ 'diag.empty': '(empty)',
112
+ 'ui.error_title': '⚠️ ClineBot UI Error: ',
113
+ 'ui.retry': 'Retry',
114
+ 'update.check_failed': 'Failed to check for updates',
115
+ 'update.retry': 'Retry check',
82
116
  'models.title': '🎯 Model Picker in DSH ({enabled} of {total} active)',
83
117
  'models.desc': 'Catalog synchronizes directly from ClinePass subscription. New plan models are enabled automatically. Uncheck models to hide them from DSH chat picker. Thinking effort is selectable in the composer model menu.',
84
118
  'models.reasoning_tooltip': 'Supports thinking effort selection in composer model menu (Off / Low / Medium / High / Max)',
@@ -206,6 +240,27 @@ const en = {
206
240
  'quota.window_weekly': '📅 每周滚动窗口',
207
241
  'quota.used': '已使用 {pct}%',
208
242
  'quota.reset_at': '重置时间:{time}',
243
+ 'quota.window_monthly': '📅 每月滚动窗口',
244
+ 'quota.countdown': '{time}后重置',
245
+ 'quota.warning_low': '额度低于 10%,请求可能很快会受到限制。',
246
+ 'quota.exhausted_5h': '5 小时滚动限额已接近耗尽(已用 {percentUsed}%)。在额度重置前新请求可能会被拒绝。',
247
+ 'quota.warning_5h': '低额度警告:5 小时限额已消耗 {percentUsed}%(剩余不足 10%)。',
248
+ 'quota.warning_weekly': '周限额偏低警告:已消耗 {percentUsed}%(剩余 {remainingPercent}%)。',
249
+ 'quota.warning_monthly': '月限额偏低警告:已消耗 {percentUsed}%(剩余 {remainingPercent}%)。',
250
+ 'models.reasoning_tag': '🧠 深度思考',
251
+ 'models.effort_low': '低',
252
+ 'models.effort_medium': '中',
253
+ 'models.effort_high': '高',
254
+ 'models.effort_max': '最高',
255
+ 'models.caps_vision': '视觉',
256
+ 'models.caps_general': '通用',
257
+ 'models.caps_coding': '编程',
258
+ 'diag.smoke_result': '✅ 延迟:{latency} ms | 模型:{model}\n响应:{preview}',
259
+ 'diag.empty': '(空)',
260
+ 'ui.error_title': '⚠️ ClineBot 界面错误:',
261
+ 'ui.retry': '重试',
262
+ 'update.check_failed': '检查更新失败',
263
+ 'update.retry': '重试检查',
209
264
  'models.title': '🎯 DSH 模型选择列表(已启用 {enabled}/{total})',
210
265
  'models.desc': '模型目录与 ClinePass 官方订阅实时同步。新订阅模型默认自动启用。取消勾选可在 DSH 对话模型列表中隐藏不需要的模型。支持在对话输入框上方切换推理思考强度(Effort)。',
211
266
  'models.reasoning_tooltip': '支持在对话框上方模型菜单中选择思考强度(Off / Low / Medium / High / Max)',
@@ -399,10 +454,13 @@ function createErrorBoundary() {
399
454
  }
400
455
  render() {
401
456
  if (this.state.hasError) {
457
+ const t = this.props && typeof this.props.t === 'function' ? this.props.t : null
458
+ const errorTitle = t ? t('ui.error_title') : '⚠️ ClineBot UI Error: '
459
+ const retryLabel = t ? t('ui.retry') : 'Retry'
402
460
  return React.createElement(
403
461
  'div',
404
462
  { className: 'cb-alert-err' },
405
- React.createElement('strong', null, '⚠️ ClineBot UI Error: '),
463
+ React.createElement('strong', null, errorTitle),
406
464
  String(this.state.error?.message || this.state.error || 'Unknown rendering error'),
407
465
  React.createElement(
408
466
  'button',
@@ -412,7 +470,7 @@ function createErrorBoundary() {
412
470
  style: { marginLeft: '12px', padding: '2px 8px', fontSize: '11px' },
413
471
  onClick: () => this.setState({ hasError: false, error: null }),
414
472
  },
415
- 'Retry'
473
+ retryLabel
416
474
  )
417
475
  )
418
476
  }
@@ -537,7 +595,22 @@ function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
537
595
  }
538
596
 
539
597
  // --- src/client/components/update-banner.js ---
540
- function UpdateBanner({ updateState, handleTriggerUpdate, t }) {
598
+ function UpdateBanner({ updateState, handleTriggerUpdate, handleCheckUpdate, t }) {
599
+ const status = updateState.status || (
600
+ updateState.checking
601
+ ? 'checking'
602
+ : (updateState.latestCheckFailed
603
+ ? 'error'
604
+ : (updateState.updateAvailable
605
+ ? 'available'
606
+ : (updateState.lastCheckedAt ? 'current' : 'idle')))
607
+ )
608
+
609
+ const isChecking = status === 'checking' || updateState.checking
610
+ const isAvailable = status === 'available' || updateState.updateAvailable
611
+ const isCurrent = status === 'current'
612
+ const isError = status === 'error' || updateState.latestCheckFailed
613
+
541
614
  return React.createElement(
542
615
  React.Fragment,
543
616
  null,
@@ -561,19 +634,25 @@ function UpdateBanner({ updateState, handleTriggerUpdate, t }) {
561
634
  `v${updateState.currentVersion}`
562
635
  )
563
636
  : null,
564
- updateState.checking
637
+ isChecking
565
638
  ? React.createElement('span', { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: '12px' } },
566
639
  t('update.checking')
567
640
  )
568
- : updateState.updateAvailable
641
+ : isAvailable
569
642
  ? React.createElement('span', { className: 'cb-badge cb-badge-warn' },
570
643
  t('update.available', { latestVersion: updateState.latestVersion, currentVersion: updateState.currentVersion })
571
644
  )
572
- : React.createElement('span', { className: 'cb-badge cb-badge-ok' },
573
- '✓ ' + t('update.up_to_date')
574
- )
645
+ : isCurrent
646
+ ? React.createElement('span', { className: 'cb-badge cb-badge-ok' },
647
+ '✓ ' + t('update.up_to_date')
648
+ )
649
+ : isError
650
+ ? React.createElement('span', { className: 'cb-badge cb-badge-bad' },
651
+ t('update.check_failed')
652
+ )
653
+ : null
575
654
  ),
576
- updateState.updateAvailable
655
+ isAvailable
577
656
  ? React.createElement(
578
657
  'button',
579
658
  {
@@ -585,7 +664,19 @@ function UpdateBanner({ updateState, handleTriggerUpdate, t }) {
585
664
  },
586
665
  updateState.updating ? t('update.updating') : t('update.btn')
587
666
  )
588
- : null
667
+ : isError && handleCheckUpdate
668
+ ? React.createElement(
669
+ 'button',
670
+ {
671
+ type: 'button',
672
+ className: 'cb-btn',
673
+ disabled: isChecking,
674
+ onClick: handleCheckUpdate,
675
+ style: { padding: '5px 12px', fontSize: '12px' }
676
+ },
677
+ t('update.retry')
678
+ )
679
+ : null
589
680
  ),
590
681
  updateState.notice ? React.createElement('div', { className: 'cb-alert-ok' }, '✓ ' + updateState.notice) : null,
591
682
  updateState.error ? React.createElement('div', { className: 'cb-alert-err' }, updateState.error) : null
@@ -936,7 +1027,7 @@ function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, t }
936
1027
  React.createElement(
937
1028
  'div',
938
1029
  { style: { flex: 1 } },
939
- React.createElement('div', null, status.quotaWarning.message),
1030
+ React.createElement('div', null, (status.quotaWarning.code && t(status.quotaWarning.code, status.quotaWarning.params)) || status.quotaWarning.message),
940
1031
  status.quotaWarning.resetsAt
941
1032
  ? React.createElement(
942
1033
  'div',
@@ -1552,19 +1643,20 @@ function ModelsSection({
1552
1643
  m.category === 'reasoning'
1553
1644
  )
1554
1645
  const category = m.category && m.category !== 'reasoning' ? m.category : (hasReasoning ? null : 'general')
1646
+ const categoryLabel = category === 'general' ? t('models.caps_general') : (category === 'coding' ? t('models.caps_coding') : category)
1555
1647
  const hasVision = Boolean(m.input?.includes('image') || m.input?.includes('vision'))
1556
1648
  return React.createElement(
1557
1649
  React.Fragment,
1558
1650
  null,
1559
- category ? React.createElement('span', { className: 'cb-badge' }, category) : null,
1651
+ category ? React.createElement('span', { className: 'cb-badge' }, categoryLabel) : null,
1560
1652
  hasVision
1561
- ? React.createElement('span', { className: 'cb-badge', style: { marginLeft: category ? '4px' : '0' } }, 'Vision')
1653
+ ? React.createElement('span', { className: 'cb-badge', style: { marginLeft: category ? '4px' : '0' } }, t('models.caps_vision'))
1562
1654
  : null,
1563
1655
  hasReasoning
1564
1656
  ? React.createElement(
1565
1657
  React.Fragment,
1566
1658
  null,
1567
- React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { marginLeft: (category || hasVision) ? '4px' : '0' }, title: t('models.reasoning_tooltip') }, '🧠 Reasoning'),
1659
+ React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { marginLeft: (category || hasVision) ? '4px' : '0' }, title: t('models.reasoning_tooltip') }, t('models.reasoning_tag')),
1568
1660
  React.createElement(
1569
1661
  'select',
1570
1662
  {
@@ -1579,10 +1671,10 @@ function ModelsSection({
1579
1671
  },
1580
1672
  },
1581
1673
  React.createElement('option', { value: '' }, t('models.effort_auto')),
1582
- React.createElement('option', { value: 'low' }, 'Low'),
1583
- React.createElement('option', { value: 'medium' }, 'Medium'),
1584
- React.createElement('option', { value: 'high' }, 'High'),
1585
- React.createElement('option', { value: 'max' }, 'Max')
1674
+ React.createElement('option', { value: 'low' }, t('models.effort_low')),
1675
+ React.createElement('option', { value: 'medium' }, t('models.effort_medium')),
1676
+ React.createElement('option', { value: 'high' }, t('models.effort_high')),
1677
+ React.createElement('option', { value: 'max' }, t('models.effort_max'))
1586
1678
  )
1587
1679
  )
1588
1680
  : null
@@ -1609,10 +1701,19 @@ function StatsSection({ status, onResetStats, busy, t }) {
1609
1701
  const [confirmReset, setConfirmReset] = React.useState(false)
1610
1702
 
1611
1703
  const s = status?.statsSummary || {}
1704
+ const totals = s.totals || {}
1612
1705
  const sess = status?.sessionStats || {}
1613
1706
 
1707
+ const requests = typeof totals.requests === 'number' ? totals.requests : (typeof s.requests === 'number' ? s.requests : 0)
1708
+ const totalTokens = typeof totals.totalTokens === 'number' ? totals.totalTokens : (typeof s.totalTokens === 'number' ? s.totalTokens : 0)
1709
+ const errors429 = typeof totals.rateLimited429 === 'number' ? totals.rateLimited429 : (typeof s.errors429 === 'number' ? s.errors429 : (typeof totals.failed === 'number' ? totals.failed : 0))
1710
+
1614
1711
  const topModels = Object.entries(s.byModel || {})
1615
- .sort((a, b) => (b[1].tokens || 0) - (a[1].tokens || 0))
1712
+ .sort((a, b) => {
1713
+ const bTokens = Number(b[1]?.totalTokens ?? b[1]?.tokens ?? 0)
1714
+ const aTokens = Number(a[1]?.totalTokens ?? a[1]?.tokens ?? 0)
1715
+ return bTokens - aTokens
1716
+ })
1616
1717
  .slice(0, 5)
1617
1718
 
1618
1719
  return React.createElement(
@@ -1674,19 +1775,19 @@ function StatsSection({ status, onResetStats, busy, t }) {
1674
1775
  React.createElement(
1675
1776
  'div',
1676
1777
  { className: 'cb-stat-box' },
1677
- React.createElement('div', { className: 'cb-stat-val' }, `${s.requests || 0} (${sess.successfulRequests || 0} sess)`),
1778
+ React.createElement('div', { className: 'cb-stat-val' }, `${requests} (${sess.successfulRequests || 0} sess)`),
1678
1779
  React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.requests'))
1679
1780
  ),
1680
1781
  React.createElement(
1681
1782
  'div',
1682
1783
  { className: 'cb-stat-box' },
1683
- React.createElement('div', { className: 'cb-stat-val' }, `${Number(s.totalTokens || 0).toLocaleString()}`),
1784
+ React.createElement('div', { className: 'cb-stat-val' }, `${Number(totalTokens).toLocaleString()}`),
1684
1785
  React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.tokens'))
1685
1786
  ),
1686
1787
  React.createElement(
1687
1788
  'div',
1688
1789
  { className: 'cb-stat-box' },
1689
- React.createElement('div', { className: 'cb-stat-val' }, `${s.errors429 || 0}`),
1790
+ React.createElement('div', { className: 'cb-stat-val' }, `${errors429}`),
1690
1791
  React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.errors429'))
1691
1792
  ),
1692
1793
  React.createElement(
@@ -1719,7 +1820,7 @@ function StatsSection({ status, onResetStats, busy, t }) {
1719
1820
  },
1720
1821
  },
1721
1822
  React.createElement('span', { style: { fontFamily: 'monospace' } }, modId),
1722
- React.createElement('span', null, `${data.requests || 0} reqs · ${Number(data.tokens || 0).toLocaleString()} tok`)
1823
+ React.createElement('span', null, `${data.requests || 0} reqs · ${Number(data.totalTokens ?? data.tokens ?? 0).toLocaleString()} tok`)
1723
1824
  )
1724
1825
  )
1725
1826
  )
@@ -1776,27 +1877,17 @@ function DiagSection({ keyPresent, isRegistered, busy, smokeResult, handleSmoke,
1776
1877
  ? React.createElement(
1777
1878
  'div',
1778
1879
  { className: 'cb-preview' },
1779
- `✅ Latency: ${smokeResult.latencyMs} ms | Model: ${smokeResult.model}
1780
- Response: ${smokeResult.preview || '(empty)'}`
1880
+ t('diag.smoke_result', {
1881
+ latency: smokeResult.latencyMs,
1882
+ model: smokeResult.model,
1883
+ preview: smokeResult.preview || t('diag.empty'),
1884
+ })
1781
1885
  )
1782
1886
  : null
1783
1887
  )
1784
1888
  }
1785
1889
 
1786
1890
  // --- src/client/settings-page.js ---
1787
- function useConfigFormsSnapshot(ctx) {
1788
- const scope = React.useMemo(() => {
1789
- try { return readConfigForms(ctx)?.get?.(NS) || undefined } catch { return undefined }
1790
- }, [ctx])
1791
- const subscribe = React.useMemo(() => (cb) => {
1792
- try { return scope?.subscribe ? (scope.subscribe(cb) || (() => {})) : () => {} } catch { return () => {} }
1793
- }, [scope])
1794
- const getSnapshot = React.useCallback(() => {
1795
- try { return scope?.getSnapshot?.() || SNAPSHOT_READY } catch { return SNAPSHOT_READY }
1796
- }, [scope])
1797
- return React.useSyncExternalStore(subscribe, getSnapshot, () => SNAPSHOT_READY)?.status || 'loading'
1798
- }
1799
-
1800
1891
  function SettingsPage(props) {
1801
1892
  const ctx = props?.ctx
1802
1893
  const t = props?.t || makeT(en, en)
@@ -1810,8 +1901,9 @@ function SettingsPage(props) {
1810
1901
  const [smokeResult, setSmokeResult] = React.useState(null)
1811
1902
 
1812
1903
  const [updateState, setUpdateState] = React.useState({
1813
- checking: false, updating: false, currentVersion: '', latestVersion: '',
1814
- updateAvailable: false, canAutoUpdate: true, error: '', notice: '',
1904
+ status: 'idle', checking: false, updating: false, currentVersion: '', latestVersion: '',
1905
+ updateAvailable: false, canAutoUpdate: true, latestCheckFailed: false, lastCheckedAt: null,
1906
+ error: '', notice: '',
1815
1907
  })
1816
1908
 
1817
1909
  const [apiKeyInput, setApiKeyInput] = React.useState('')
@@ -1827,8 +1919,9 @@ function SettingsPage(props) {
1827
1919
  ])
1828
1920
  const data = await res.json().catch(() => ({}))
1829
1921
  if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
1830
- const statsData = statsRes && statsRes.ok ? await statsRes.json().catch(() => null) : null
1831
- setStatus({ ...data, statsSummary: statsData || data.statsSummary })
1922
+ const rawStats = statsRes && statsRes.ok ? await statsRes.json().catch(() => null) : null
1923
+ const statsObj = (rawStats && typeof rawStats === 'object' && rawStats.stats) ? rawStats.stats : (rawStats || data.statsSummary || {})
1924
+ setStatus({ ...data, statsSummary: statsObj })
1832
1925
  setDraft(data.config || {})
1833
1926
  }, [])
1834
1927
 
@@ -1837,23 +1930,45 @@ function SettingsPage(props) {
1837
1930
  }, [load])
1838
1931
 
1839
1932
  const checkUpdate = React.useCallback(async () => {
1840
- setUpdateState((s) => ({ ...s, checking: true, error: '' }))
1933
+ setUpdateState((s) => ({ ...s, checking: true, status: 'checking', error: '' }))
1841
1934
  try {
1842
1935
  const res = await fetch(`${ROUTE_PREFIX}/update`)
1843
1936
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
1844
1937
  const data = await res.json().catch(() => ({}))
1938
+ if (data.latestCheckFailed) {
1939
+ setUpdateState((s) => ({
1940
+ ...s,
1941
+ checking: false,
1942
+ status: 'error',
1943
+ latestCheckFailed: true,
1944
+ currentVersion: data.currentVersion || s.currentVersion,
1945
+ error: t('update.check_failed'),
1946
+ }))
1947
+ return
1948
+ }
1949
+ const isAvailable = Boolean(data.updateAvailable)
1845
1950
  setUpdateState((s) => ({
1846
1951
  ...s,
1847
1952
  checking: false,
1953
+ status: isAvailable ? 'available' : 'current',
1848
1954
  currentVersion: data.currentVersion || s.currentVersion,
1849
1955
  latestVersion: data.latestVersion || '',
1850
- updateAvailable: !!data.updateAvailable,
1956
+ updateAvailable: isAvailable,
1957
+ latestCheckFailed: false,
1958
+ lastCheckedAt: Date.now(),
1851
1959
  canAutoUpdate: data.canAutoUpdate !== false,
1960
+ error: '',
1961
+ }))
1962
+ } catch (err) {
1963
+ setUpdateState((s) => ({
1964
+ ...s,
1965
+ checking: false,
1966
+ status: 'error',
1967
+ latestCheckFailed: true,
1968
+ error: String(err?.message || err || t('update.check_failed')),
1852
1969
  }))
1853
- } catch (_) {
1854
- setUpdateState((s) => ({ ...s, checking: false }))
1855
1970
  }
1856
- }, [])
1971
+ }, [t])
1857
1972
 
1858
1973
  React.useEffect(() => { checkUpdate() }, [checkUpdate])
1859
1974
 
@@ -1871,13 +1986,20 @@ function SettingsPage(props) {
1871
1986
  setUpdateState((s) => ({
1872
1987
  ...s,
1873
1988
  updating: false,
1989
+ status: 'current',
1874
1990
  updateAvailable: false,
1875
1991
  currentVersion: newVer,
1992
+ lastCheckedAt: Date.now(),
1876
1993
  notice: t('update.done', { version: newVer }),
1877
1994
  }))
1878
1995
  setTimeout(() => checkUpdate(), 2000)
1879
1996
  } catch (err) {
1880
- setUpdateState((s) => ({ ...s, updating: false, error: t('update.failed', { error: String(err.message || err) }) }))
1997
+ setUpdateState((s) => ({
1998
+ ...s,
1999
+ updating: false,
2000
+ status: s.updateAvailable ? 'available' : 'error',
2001
+ error: t('update.failed', { error: String(err?.message || err) }),
2002
+ }))
1881
2003
  }
1882
2004
  }
1883
2005
 
@@ -1899,8 +2021,7 @@ function SettingsPage(props) {
1899
2021
  if (!keyVal) { setErr(t('key.empty_err')); return }
1900
2022
  await performAction('save-key', async () => {
1901
2023
  const res = await fetch(`${ROUTE_PREFIX}/save-key`, {
1902
- method: 'POST',
1903
- headers: { 'Content-Type': 'application/json' },
2024
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1904
2025
  body: JSON.stringify({ apiKey: keyVal, apiKeyEnv: draft?.apiKeyEnv }),
1905
2026
  })
1906
2027
  const data = await res.json().catch(() => ({}))
@@ -1924,8 +2045,7 @@ function SettingsPage(props) {
1924
2045
  async function handleUpdateModelContext(payload) {
1925
2046
  await performAction('update-context', async () => {
1926
2047
  const res = await fetch(`${ROUTE_PREFIX}/models/context`, {
1927
- method: 'POST',
1928
- headers: { 'Content-Type': 'application/json' },
2048
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1929
2049
  body: JSON.stringify(payload),
1930
2050
  })
1931
2051
  const data = await res.json().catch(() => ({}))
@@ -1994,8 +2114,7 @@ function SettingsPage(props) {
1994
2114
  setVerifyStatus({ state: 'verifying', email: '', plan: '', error: '' })
1995
2115
  try {
1996
2116
  const res = await fetch(`${ROUTE_PREFIX}/key/verify`, {
1997
- method: 'POST',
1998
- headers: { 'Content-Type': 'application/json' },
2117
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1999
2118
  body: JSON.stringify({ key: raw }),
2000
2119
  })
2001
2120
  const data = await res.json().catch(() => ({}))
@@ -2020,8 +2139,7 @@ function SettingsPage(props) {
2020
2139
  async function handlePinAccount(accountEnv) {
2021
2140
  await performAction('pin-account', async () => {
2022
2141
  const res = await fetch(`${ROUTE_PREFIX}/accounts/active`, {
2023
- method: 'POST',
2024
- headers: { 'Content-Type': 'application/json' },
2142
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
2025
2143
  body: JSON.stringify({ account: accountEnv }),
2026
2144
  })
2027
2145
  const data = await res.json().catch(() => ({}))
@@ -2033,8 +2151,7 @@ function SettingsPage(props) {
2033
2151
  async function handleAddAccount({ label, apiKeyEnv, apiKey }) {
2034
2152
  await performAction('add-account', async () => {
2035
2153
  const res = await fetch(`${ROUTE_PREFIX}/accounts`, {
2036
- method: 'POST',
2037
- headers: { 'Content-Type': 'application/json' },
2154
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
2038
2155
  body: JSON.stringify({ label, apiKeyEnv, apiKey }),
2039
2156
  })
2040
2157
  const data = await res.json().catch(() => ({}))
@@ -2047,8 +2164,7 @@ function SettingsPage(props) {
2047
2164
  async function handleDeleteAccount(accountEnv, deleteSecret = true) {
2048
2165
  await performAction('delete-account', async () => {
2049
2166
  const res = await fetch(`${ROUTE_PREFIX}/accounts/delete`, {
2050
- method: 'POST',
2051
- headers: { 'Content-Type': 'application/json' },
2167
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
2052
2168
  body: JSON.stringify({ apiKeyEnv: accountEnv, deleteSecret }),
2053
2169
  })
2054
2170
  const data = await res.json().catch(() => ({}))
@@ -2058,14 +2174,14 @@ function SettingsPage(props) {
2058
2174
  }
2059
2175
 
2060
2176
  async function handleConfigPatch(patch) {
2177
+ setDraft((d) => ({ ...(d || {}), ...(patch || {}) }))
2061
2178
  await performAction('patch-config', async () => {
2062
2179
  const res = await fetch(`${ROUTE_PREFIX}/config`, {
2063
- method: 'POST',
2064
- headers: { 'Content-Type': 'application/json' },
2065
- body: JSON.stringify(patch),
2180
+ method: 'PUT', headers: { 'Content-Type': 'application/json' },
2181
+ body: JSON.stringify({ config: patch }),
2066
2182
  })
2067
2183
  const data = await res.json().catch(() => ({}))
2068
- if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
2184
+ if (!res.ok || data.ok === false) throw new Error(data.error || `HTTP ${res.status}`)
2069
2185
  await load()
2070
2186
  })
2071
2187
  }
@@ -2125,15 +2241,10 @@ function SettingsPage(props) {
2125
2241
  function handleSetModelsFilter(type) {
2126
2242
  const all = status?.availableModels || []
2127
2243
  let allowed = new Set()
2128
- if (type === 'all') {
2129
- allowed = new Set(all.map((m) => m.id))
2130
- } else if (type === 'vision') {
2131
- allowed = new Set(all.filter((m) => m.input?.includes('image') || m.input?.includes('vision')).map((m) => m.id))
2132
- } else if (type === 'coding') {
2133
- allowed = new Set(all.filter((m) => m.category === 'coding').map((m) => m.id))
2134
- } else if (type === 'recommended') {
2135
- allowed = new Set(all.filter((m) => m.recommended).map((m) => m.id))
2136
- }
2244
+ if (type === 'all') allowed = new Set(all.map((m) => m.id))
2245
+ else if (type === 'vision') allowed = new Set(all.filter((m) => m.input?.includes('image') || m.input?.includes('vision')).map((m) => m.id))
2246
+ else if (type === 'coding') allowed = new Set(all.filter((m) => m.category === 'coding').map((m) => m.id))
2247
+ else if (type === 'recommended') allowed = new Set(all.filter((m) => m.recommended).map((m) => m.id))
2137
2248
 
2138
2249
  const curr = draft?.disabledModels || []
2139
2250
  const nextDisabled = all.map((m) => m.id).filter((id) => !allowed.has(id))
@@ -2194,7 +2305,7 @@ function SettingsPage(props) {
2194
2305
  ),
2195
2306
 
2196
2307
  // In-app Update Bar
2197
- React.createElement(UpdateBanner, { updateState, handleTriggerUpdate, t }),
2308
+ React.createElement(UpdateBanner, { updateState, handleTriggerUpdate, handleCheckUpdate: checkUpdate, t }),
2198
2309
 
2199
2310
  // Settings host status warning
2200
2311
  snapshotStatus === 'unavailable'
@@ -2327,7 +2438,7 @@ function PluginCard(props) {
2327
2438
  { style: { marginTop: '16px' } },
2328
2439
  React.createElement(
2329
2440
  ErrorBoundary,
2330
- null,
2441
+ { t },
2331
2442
  React.createElement(SettingsPage, { ...props, ctx: props && props.ctx })
2332
2443
  )
2333
2444
  )
@@ -2412,7 +2523,7 @@ function apply(ctx) {
2412
2523
  locale: NS,
2413
2524
  inject: () => ({ ctx }),
2414
2525
  },
2415
- (props) => React.createElement(ErrorBoundary, null, React.createElement(PluginCard, { ...props, ctx: (props && props.ctx) || ctx }))
2526
+ (props) => React.createElement(ErrorBoundary, { t: props && props.t }, React.createElement(PluginCard, { ...props, ctx: (props && props.ctx) || ctx }))
2416
2527
  )
2417
2528
  )
2418
2529
 
@@ -2429,7 +2540,7 @@ function apply(ctx) {
2429
2540
  locale: NS,
2430
2541
  inject: () => ({ ctx }),
2431
2542
  },
2432
- (props) => React.createElement(ErrorBoundary, null, React.createElement(PluginCard, { ...props, ctx: (props && props.ctx) || ctx }))
2543
+ (props) => React.createElement(ErrorBoundary, { t: props && props.t }, React.createElement(PluginCard, { ...props, ctx: (props && props.ctx) || ctx }))
2433
2544
  )
2434
2545
  )
2435
2546
  }
@@ -2442,7 +2553,7 @@ function apply(ctx) {
2442
2553
  locale: NS,
2443
2554
  inject: () => ({ ctx }),
2444
2555
  },
2445
- (props) => React.createElement(ErrorBoundary, null, React.createElement(PluginCard, { ...props, ctx: (props && props.ctx) || ctx }))
2556
+ (props) => React.createElement(ErrorBoundary, { t: props && props.t }, React.createElement(PluginCard, { ...props, ctx: (props && props.ctx) || ctx }))
2446
2557
  )
2447
2558
  )
2448
2559
  }
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto'
1
2
  export {
2
3
  toCredentialRef,
3
4
  resolveApiKey,
@@ -87,8 +88,32 @@ export function clearProbeCache() {
87
88
  probeCache.clear()
88
89
  }
89
90
 
90
- export function clearUsageCache() {
91
- usageCache.clear()
91
+ /**
92
+ * Build an opaque, collision-free cache key from normalized baseUrl and full apiKey.
93
+ * Never leaks the raw apiKey or fingerprint into UI/logs.
94
+ *
95
+ * @param {string} baseUrl
96
+ * @param {string} apiKey
97
+ * @returns {string}
98
+ */
99
+ export function buildUsageCacheKey(baseUrl = '', apiKey = '') {
100
+ const normBase = normalizeBaseUrl(baseUrl)
101
+ const normKey = String(apiKey || '').trim()
102
+ if (!normKey) return ''
103
+ const hash = createHash('sha256')
104
+ .update(`${normBase}\0${normKey}`)
105
+ .digest('hex')
106
+ .slice(0, 32)
107
+ return `cline:usage:${hash}`
108
+ }
109
+
110
+ export function clearUsageCache(baseUrl, apiKey) {
111
+ if (baseUrl && apiKey) {
112
+ const key = buildUsageCacheKey(baseUrl, apiKey)
113
+ usageCache.delete(key)
114
+ } else {
115
+ usageCache.clear()
116
+ }
92
117
  }
93
118
 
94
119
  /**
@@ -106,6 +131,17 @@ export async function retryWithBackoff(fn, {
106
131
  const res = await fn()
107
132
  if (res && typeof res.status === 'number' && [429, 502, 503, 504].includes(res.status) && attempt < maxRetries) {
108
133
  attempt++
134
+ if (res.body) {
135
+ try {
136
+ if (typeof res.body.cancel === 'function') {
137
+ res.body.cancel().catch(() => {})
138
+ } else if (typeof res.body.destroy === 'function') {
139
+ res.body.destroy()
140
+ }
141
+ } catch {
142
+ /* ignore cancel error */
143
+ }
144
+ }
109
145
  const retryAfter = res.headers?.get ? Number(res.headers.get('retry-after')) * 1000 : 0
110
146
  const waitTime = retryAfter > 0 ? Math.min(retryAfter, maxDelayMs) : delay
111
147
  await new Promise((r) => setTimeout(r, waitTime))
@@ -140,7 +176,7 @@ export async function fetchUsageLimits(baseUrl, apiKey, {
140
176
  return { ok: false, error: 'API key is missing' }
141
177
  }
142
178
 
143
- const cacheKey = `cline:usage:${apiKey.slice(-8)}`
179
+ const cacheKey = buildUsageCacheKey(base, apiKey)
144
180
  const now = Date.now()
145
181
 
146
182
  const doFetch = async () => {
@@ -450,15 +486,20 @@ export function buildPiAiProvider({
450
486
  baseUrl = DEFAULT_BASE_URL,
451
487
  apiKeyEnv = DEFAULT_API_KEY_ENV,
452
488
  apiKey,
489
+ dynamicModels = [],
490
+ modelContextOverrides = [],
453
491
  models = [],
454
492
  customModels = [],
455
493
  displayName = PROVIDER_DISPLAY_NAME,
456
494
  } = {}) {
457
- const allAvailable = getAllModels(customModels)
495
+ const allAvailable = getAllModels(dynamicModels, modelContextOverrides, customModels)
496
+ const customModelsMap = new Map((Array.isArray(customModels) ? customModels : []).filter(Boolean).map(cm => [cm.id, cm]))
458
497
  const modelList = (Array.isArray(models) && models.length ? models : allAvailable).map((m) => {
459
498
  let item
460
499
  if (typeof m === 'string') {
461
- item = allAvailable.find((x) => x.id === m) || { id: m, name: m }
500
+ const customMatch = customModelsMap.get(m)
501
+ const baseMatch = allAvailable.find((x) => x.id === m)
502
+ item = customMatch ? { ...baseMatch, ...customMatch } : (baseMatch || { id: m, name: m })
462
503
  } else {
463
504
  item = m
464
505
  }
@@ -507,7 +548,6 @@ export function buildPiAiProvider({
507
548
  api: 'openai-completions',
508
549
  baseURL: normalizeBaseUrl(baseUrl),
509
550
  apiKeyEnv: apiKeyEnv || DEFAULT_API_KEY_ENV,
510
- ...(apiKey ? { apiKey } : {}),
511
551
  compat: {
512
552
  supportsReasoningEffort: true,
513
553
  sendSessionAffinityHeaders: true,