@goodandready/dsh-clinebot 0.4.7 → 0.4.8

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,15 @@ 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.4.8] - 2026-09-27
9
+
10
+ ### Added
11
+ - **Transparent Loopback Proxy (#127)**: High-performance streaming proxy endpoints at `/dsh-clinebot/v1/chat/completions` and `/dsh-clinebot/v1/models` compatible with OpenAI spec. Features zero-downtime retry on HTTP 429 before stream commits, live SSE token tracking, and direct binding into DSH `llm-pi-ai` provider.
12
+ - **Sticky Session Least-Used Routing (#128)**: Session-aware routing selecting account with maximum quota remaining (`remainingPercent`) at session inception. Pins active session to selected account and triggers seamless failover on quota exhaustion or HTTP 429, with automatic cooldown expiration recovery.
13
+ - **Persistent JSON Token Analytics (#129)**: Atomic file-based telemetry storage at `~/.dsh/clinebot-stats.json` tracking cumulative prompt/completion tokens, requests, and rate limits across models and accounts with debounced write operations. Accessible via `GET /dsh-clinebot/stats` and resettable via `POST /dsh-clinebot/stats/reset`.
14
+ - **Per-Model Reasoning Effort Defaults & Custom Models (#130)**: Fine-grained `reasoning_effort` defaults (`low`, `medium`, `high`, `max`) per reasoning model in configuration and UI. Added support for user-defined custom models (`config.customModels`) integrated into dynamic catalog.
15
+ - **Quota UX & Proactive Monitoring (#131)**: Human-readable reset countdown timer (`formatResetCountdown`) for rolling weekly and monthly quotas, proactive warning banners in settings and progress bars when remaining quota drops below 10%, and monthly billing cycle visibility.
16
+
8
17
  ## [0.4.7] - 2026-09-27
9
18
 
10
19
  ### Fixed & Cleaned
package/lib/client.js CHANGED
@@ -122,6 +122,17 @@ const en = {
122
122
  'stats.tokens': 'Estimated Session Tokens',
123
123
  'stats.latency': 'Last Request Latency',
124
124
  'stats.last_req': 'Last Request Time',
125
+ 'models.custom_add_title': 'Custom Models',
126
+ 'models.custom_id': 'Model ID (e.g. anthropic/claude-3-7-sonnet)',
127
+ 'models.custom_name': 'Display Name (optional)',
128
+ 'models.custom_btn_add': 'Add Model',
129
+ 'models.custom_btn_del': 'Delete',
130
+ 'models.effort_auto': 'Auto Effort',
131
+ 'stats.errors429': '429 Rate Limits (Failover)',
132
+ 'stats.top_models': 'Top Models by Token Usage',
133
+ 'stats.reset_btn': 'Reset Stats',
134
+ 'stats.confirm_reset': 'Confirm Reset?',
135
+ 'stats.cancel_reset': 'Cancel',
125
136
  'diag.title': '⚡ Diagnostics & Auto-Registration',
126
137
  'diag.desc': 'ClineBot registers into DSH Models automatically when key is configured. You can run connection smoke test or force re-sync.',
127
138
  'diag.smoke_btn': 'Run Smoke Test (Ping)',
@@ -238,6 +249,17 @@ const en = {
238
249
  'stats.tokens': '会话累计 Token 估算',
239
250
  'stats.latency': '最近一次请求延迟',
240
251
  'stats.last_req': '最近请求时间',
252
+ 'models.custom_add_title': '自定义模型',
253
+ 'models.custom_id': '模型 ID (例如 anthropic/claude-3-7-sonnet)',
254
+ 'models.custom_name': '显示名称 (可选)',
255
+ 'models.custom_btn_add': '添加模型',
256
+ 'models.custom_btn_del': '删除',
257
+ 'models.effort_auto': '自动思考强度',
258
+ 'stats.errors429': '429 限流保护次数',
259
+ 'stats.top_models': 'Token 消耗最高模型',
260
+ 'stats.reset_btn': '重置统计',
261
+ 'stats.confirm_reset': '确认重置?',
262
+ 'stats.cancel_reset': '取消',
241
263
  'diag.title': '⚡ 诊断工具与服务接入',
242
264
  'diag.desc': '配置 API 密钥后,ClineBot 会自动向 DSH 注册模型服务。您可以在此进行连通性测试或强制重新同步。',
243
265
  'diag.smoke_btn': '执行连通性冒烟测试 (Ping)',
@@ -404,6 +426,29 @@ function createErrorBoundary() {
404
426
  const ErrorBoundary = createErrorBoundary()
405
427
 
406
428
  // --- src/client/progress-bar.js ---
429
+ function formatResetCountdown(isoString) {
430
+ if (!isoString) return ''
431
+ try {
432
+ const d = new Date(isoString)
433
+ const diff = d.getTime() - Date.now()
434
+ if (Number.isNaN(diff)) return ''
435
+ if (diff <= 0) return 'resetting'
436
+ const totalHours = Math.floor(diff / (1000 * 60 * 60))
437
+ const days = Math.floor(totalHours / 24)
438
+ const hours = totalHours % 24
439
+ if (days > 0) {
440
+ return `${days}d ${hours}h`
441
+ }
442
+ const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
443
+ if (hours > 0) {
444
+ return `${hours}h ${minutes}m`
445
+ }
446
+ return `${Math.max(1, minutes)}m`
447
+ } catch {
448
+ return ''
449
+ }
450
+ }
451
+
407
452
  function formatResetTime(isoString) {
408
453
  if (!isoString) return ''
409
454
  try {
@@ -417,10 +462,11 @@ function formatResetTime(isoString) {
417
462
 
418
463
  function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
419
464
  const pct = Math.max(0, Math.min(100, Math.round(percentUsed || 0)))
420
- const isWarn = pct >= 80 && pct < 95
421
- const isBad = pct >= 95
465
+ const isWarn = pct >= 80 && pct < 90
466
+ const isBad = pct >= 90
422
467
  const barClass = isBad ? 'cb-progress-bar-bad' : isWarn ? 'cb-progress-bar-warn' : 'cb-progress-bar-fill'
423
- const resetLabel = formatResetTime(resetsAt)
468
+ const resetCountdown = formatResetCountdown(resetsAt)
469
+ const resetClock = formatResetTime(resetsAt)
424
470
 
425
471
  return React.createElement(
426
472
  'div',
@@ -432,7 +478,7 @@ function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
432
478
  gap: '8px',
433
479
  padding: '12px 14px',
434
480
  borderRadius: '8px',
435
- border: '1px solid var(--dsw-alias-border-l2)',
481
+ border: isBad ? '1px solid var(--dsw-alias-error, #f87171)' : '1px solid var(--dsw-alias-border-l2)',
436
482
  background: 'var(--dsw-alias-bg-layer-2)',
437
483
  },
438
484
  },
@@ -448,20 +494,25 @@ function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
448
494
  width: '100%',
449
495
  },
450
496
  },
451
- React.createElement('span', { className: 'cb-progress-label', style: { fontWeight: 500, whiteSpace: 'nowrap' } }, label),
497
+ React.createElement(
498
+ 'span',
499
+ { className: 'cb-progress-label', style: { fontWeight: 500, whiteSpace: 'nowrap' } },
500
+ label,
501
+ isBad ? React.createElement('span', { style: { marginLeft: '6px', fontSize: '11px', color: 'var(--dsw-alias-error, #f87171)' } }, '⚠️ <10%') : null
502
+ ),
452
503
  React.createElement(
453
504
  'span',
454
505
  {
455
506
  className: 'cb-progress-meta cb-bar-meta',
456
507
  style: {
457
508
  fontSize: '12px',
458
- color: 'var(--dsw-alias-label-secondary)',
509
+ color: isBad ? 'var(--dsw-alias-error, #f87171)' : 'var(--dsw-alias-label-secondary)',
459
510
  whiteSpace: 'nowrap',
460
511
  marginLeft: 'auto',
461
512
  },
462
513
  },
463
514
  t('quota.used', { pct }),
464
- resetLabel ? ` · ${t('quota.reset_at', { time: resetLabel })}` : ''
515
+ resetCountdown ? ` · ${t('quota.countdown', { time: resetCountdown })}` : (resetClock ? ` · ${t('quota.reset_at', { time: resetClock })}` : '')
465
516
  )
466
517
  ),
467
518
  React.createElement(
@@ -868,6 +919,12 @@ function AccountsSection({ status, busy, handlePinAccount, handleAddAccount, han
868
919
 
869
920
  // --- src/client/components/quota-section.js ---
870
921
  function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, t }) {
922
+ const isLowQuota = Boolean(
923
+ (usage?.windows?.weekly && typeof usage.windows.weekly.remainingPercent === 'number' && usage.windows.weekly.remainingPercent <= 10) ||
924
+ (usage?.windows?.monthly && typeof usage.windows.monthly.remainingPercent === 'number' && usage.windows.monthly.remainingPercent <= 10) ||
925
+ (usage?.windows?.fiveHour && typeof usage.windows.fiveHour.remainingPercent === 'number' && usage.windows.fiveHour.remainingPercent <= 10)
926
+ )
927
+
871
928
  return React.createElement(
872
929
  React.Fragment,
873
930
  null,
@@ -889,7 +946,14 @@ function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, t }
889
946
  : null
890
947
  )
891
948
  )
892
- : null,
949
+ : (isLowQuota
950
+ ? React.createElement(
951
+ 'div',
952
+ { className: 'cb-banner-warning' },
953
+ React.createElement('span', { style: { fontSize: '16px' } }, '⚠️'),
954
+ React.createElement('div', { style: { flex: 1 } }, t('quota.warning_low'))
955
+ )
956
+ : null),
893
957
  keyPresent
894
958
  ? React.createElement(
895
959
  'div',
@@ -918,27 +982,183 @@ function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, t }
918
982
  ),
919
983
  React.createElement(
920
984
  'div',
921
- { className: 'cb-grid-2' },
922
- React.createElement(ProgressBar, {
923
- label: t('quota.window_5h'),
924
- percentUsed: usage?.windows?.fiveHour?.percentUsed || 0,
925
- remainingPercent: usage?.windows?.fiveHour?.remainingPercent || 100,
926
- resetsAt: usage?.windows?.fiveHour?.resetsAt,
927
- t,
928
- }),
985
+ { className: 'cb-grid-2', style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '10px' } },
986
+ usage?.windows?.fiveHour
987
+ ? React.createElement(ProgressBar, {
988
+ label: t('quota.window_5h'),
989
+ percentUsed: usage.windows.fiveHour.percentUsed || 0,
990
+ remainingPercent: usage.windows.fiveHour.remainingPercent || 100,
991
+ resetsAt: usage.windows.fiveHour.resetsAt,
992
+ t,
993
+ })
994
+ : null,
929
995
  React.createElement(ProgressBar, {
930
996
  label: t('quota.window_weekly'),
931
997
  percentUsed: usage?.windows?.weekly?.percentUsed || 0,
932
998
  remainingPercent: usage?.windows?.weekly?.remainingPercent || 100,
933
999
  resetsAt: usage?.windows?.weekly?.resetsAt,
934
1000
  t,
935
- })
1001
+ }),
1002
+ usage?.windows?.monthly
1003
+ ? React.createElement(ProgressBar, {
1004
+ label: t('quota.window_monthly'),
1005
+ percentUsed: usage.windows.monthly.percentUsed || 0,
1006
+ remainingPercent: usage.windows.monthly.remainingPercent || 100,
1007
+ resetsAt: usage.windows.monthly.resetsAt,
1008
+ t,
1009
+ })
1010
+ : null
936
1011
  )
937
1012
  )
938
1013
  : null
939
1014
  )
940
1015
  }
941
1016
 
1017
+ // --- src/client/components/custom-models.js ---
1018
+ function CustomModelsSub({ customModels = [], onConfigUpdate, busy, t }) {
1019
+ const [newId, setNewId] = React.useState('')
1020
+ const [newName, setNewName] = React.useState('')
1021
+ const [isExpanded, setIsExpanded] = React.useState(false)
1022
+
1023
+ function handleAdd() {
1024
+ const trimmedId = newId.trim()
1025
+ const trimmedName = newName.trim() || trimmedId
1026
+ if (!trimmedId) return
1027
+ const nextList = [...customModels.filter((m) => m.id !== trimmedId), {
1028
+ id: trimmedId,
1029
+ name: trimmedName,
1030
+ contextLength: 128000,
1031
+ maxTokens: 8192,
1032
+ category: 'general',
1033
+ isCustom: true,
1034
+ }]
1035
+ if (typeof onConfigUpdate === 'function') {
1036
+ onConfigUpdate({ customModels: nextList })
1037
+ }
1038
+ setNewId('')
1039
+ setNewName('')
1040
+ }
1041
+
1042
+ function handleDelete(id) {
1043
+ const nextList = customModels.filter((m) => m.id !== id)
1044
+ if (typeof onConfigUpdate === 'function') {
1045
+ onConfigUpdate({ customModels: nextList })
1046
+ }
1047
+ }
1048
+
1049
+ return React.createElement(
1050
+ 'div',
1051
+ {
1052
+ style: {
1053
+ marginTop: '16px',
1054
+ paddingTop: '14px',
1055
+ borderTop: '1px solid var(--dsw-alias-border-l2)',
1056
+ },
1057
+ },
1058
+ React.createElement(
1059
+ 'div',
1060
+ {
1061
+ style: {
1062
+ display: 'flex',
1063
+ justifyContent: 'space-between',
1064
+ alignItems: 'center',
1065
+ cursor: 'pointer',
1066
+ },
1067
+ onClick: () => setIsExpanded(!isExpanded),
1068
+ },
1069
+ React.createElement(
1070
+ 'span',
1071
+ { style: { fontWeight: 500, fontSize: '13px', color: 'var(--dsw-alias-label-primary)' } },
1072
+ `➕ ${t('models.custom_add_title')} (${customModels.length})`
1073
+ ),
1074
+ React.createElement(
1075
+ 'span',
1076
+ { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' } },
1077
+ isExpanded ? '▲' : '▼'
1078
+ )
1079
+ ),
1080
+ isExpanded
1081
+ ? React.createElement(
1082
+ 'div',
1083
+ { style: { marginTop: '12px', display: 'flex', flexDirection: 'column', gap: '10px' } },
1084
+ React.createElement(
1085
+ 'div',
1086
+ { style: { display: 'flex', gap: '8px', flexWrap: 'wrap' } },
1087
+ React.createElement('input', {
1088
+ type: 'text',
1089
+ className: 'cb-input',
1090
+ style: { flex: '1 1 180px' },
1091
+ placeholder: t('models.custom_id'),
1092
+ value: newId,
1093
+ disabled: !!busy,
1094
+ onChange: (e) => setNewId(e.target.value),
1095
+ }),
1096
+ React.createElement('input', {
1097
+ type: 'text',
1098
+ className: 'cb-input',
1099
+ style: { flex: '1 1 180px' },
1100
+ placeholder: t('models.custom_name'),
1101
+ value: newName,
1102
+ disabled: !!busy,
1103
+ onChange: (e) => setNewName(e.target.value),
1104
+ }),
1105
+ React.createElement(
1106
+ 'button',
1107
+ {
1108
+ type: 'button',
1109
+ className: 'cb-btn cb-btn-primary',
1110
+ disabled: !newId.trim() || !!busy,
1111
+ onClick: handleAdd,
1112
+ },
1113
+ t('models.custom_btn_add')
1114
+ )
1115
+ ),
1116
+ customModels.length > 0
1117
+ ? React.createElement(
1118
+ 'div',
1119
+ { style: { display: 'flex', flexDirection: 'column', gap: '6px', marginTop: '6px' } },
1120
+ customModels.map((cm) =>
1121
+ React.createElement(
1122
+ 'div',
1123
+ {
1124
+ key: cm.id,
1125
+ style: {
1126
+ display: 'flex',
1127
+ justifyContent: 'space-between',
1128
+ alignItems: 'center',
1129
+ padding: '6px 10px',
1130
+ background: 'var(--dsw-alias-bg-layer-2)',
1131
+ border: '1px solid var(--dsw-alias-border-l2)',
1132
+ borderRadius: '6px',
1133
+ fontSize: '12px',
1134
+ },
1135
+ },
1136
+ React.createElement(
1137
+ 'div',
1138
+ null,
1139
+ React.createElement('span', { style: { fontWeight: 500 } }, cm.name || cm.id),
1140
+ React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)', marginLeft: '8px' } }, `(${cm.id})`)
1141
+ ),
1142
+ React.createElement(
1143
+ 'button',
1144
+ {
1145
+ type: 'button',
1146
+ className: 'cb-btn',
1147
+ style: { padding: '2px 8px', fontSize: '11px' },
1148
+ disabled: !!busy,
1149
+ onClick: () => handleDelete(cm.id),
1150
+ },
1151
+ t('models.custom_btn_del')
1152
+ )
1153
+ )
1154
+ )
1155
+ )
1156
+ : null
1157
+ )
1158
+ : null
1159
+ )
1160
+ }
1161
+
942
1162
  // --- src/client/components/models-section.js ---
943
1163
  function ModelsSection({
944
1164
  modelsList,
@@ -953,6 +1173,10 @@ function ModelsSection({
953
1173
  planSynced,
954
1174
  planSyncedAt,
955
1175
  defaultModelWarning,
1176
+ customModels,
1177
+ modelReasoningDefaults,
1178
+ onConfigUpdate,
1179
+ onUpdateReasoningEffort,
956
1180
  t,
957
1181
  }) {
958
1182
  const syncDateStr = planSyncedAt ? new Date(planSyncedAt).toLocaleDateString() : ''
@@ -1337,7 +1561,30 @@ function ModelsSection({
1337
1561
  ? React.createElement('span', { className: 'cb-badge', style: { marginLeft: category ? '4px' : '0' } }, 'Vision')
1338
1562
  : null,
1339
1563
  hasReasoning
1340
- ? React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { marginLeft: (category || hasVision) ? '4px' : '0' }, title: t('models.reasoning_tooltip') }, '🧠 Reasoning')
1564
+ ? React.createElement(
1565
+ React.Fragment,
1566
+ null,
1567
+ React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { marginLeft: (category || hasVision) ? '4px' : '0' }, title: t('models.reasoning_tooltip') }, '🧠 Reasoning'),
1568
+ React.createElement(
1569
+ 'select',
1570
+ {
1571
+ className: 'cb-select',
1572
+ style: { marginLeft: '4px', fontSize: '10px', padding: '1px 3px' },
1573
+ value: (modelReasoningDefaults && modelReasoningDefaults[m.id]) || '',
1574
+ disabled: !!busy,
1575
+ onChange: (e) => {
1576
+ if (typeof onUpdateReasoningEffort === 'function') {
1577
+ onUpdateReasoningEffort(m.id, e.target.value)
1578
+ }
1579
+ },
1580
+ },
1581
+ 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')
1586
+ )
1587
+ )
1341
1588
  : null
1342
1589
  )
1343
1590
  })()
@@ -1345,45 +1592,139 @@ function ModelsSection({
1345
1592
  )
1346
1593
  })
1347
1594
  )
1348
- )
1595
+ ),
1596
+ typeof CustomModelsSub === 'function'
1597
+ ? React.createElement(CustomModelsSub, {
1598
+ customModels: customModels || [],
1599
+ onConfigUpdate: onConfigUpdate,
1600
+ busy: busy,
1601
+ t: t,
1602
+ })
1603
+ : null
1349
1604
  )
1350
1605
  }
1351
1606
 
1352
1607
  // --- src/client/components/stats-section.js ---
1353
- function StatsSection({ status, t }) {
1608
+ function StatsSection({ status, onResetStats, busy, t }) {
1609
+ const [confirmReset, setConfirmReset] = React.useState(false)
1610
+
1611
+ const s = status?.statsSummary || {}
1612
+ const sess = status?.sessionStats || {}
1613
+
1614
+ const topModels = Object.entries(s.byModel || {})
1615
+ .sort((a, b) => (b[1].tokens || 0) - (a[1].tokens || 0))
1616
+ .slice(0, 5)
1617
+
1354
1618
  return React.createElement(
1355
1619
  'div',
1356
1620
  { className: 'cb-section-card' },
1357
- React.createElement('div', { className: 'cb-section-title' }, t('stats.title')),
1621
+ React.createElement(
1622
+ 'div',
1623
+ { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' } },
1624
+ React.createElement('div', { className: 'cb-section-title' }, t('stats.title')),
1625
+ React.createElement(
1626
+ 'div',
1627
+ null,
1628
+ confirmReset
1629
+ ? React.createElement(
1630
+ React.Fragment,
1631
+ null,
1632
+ React.createElement(
1633
+ 'button',
1634
+ {
1635
+ type: 'button',
1636
+ className: 'cb-btn cb-btn-warn',
1637
+ style: { padding: '2px 8px', fontSize: '11px', marginRight: '6px' },
1638
+ disabled: !!busy,
1639
+ onClick: () => {
1640
+ setConfirmReset(false)
1641
+ if (typeof onResetStats === 'function') onResetStats()
1642
+ },
1643
+ },
1644
+ t('stats.confirm_reset')
1645
+ ),
1646
+ React.createElement(
1647
+ 'button',
1648
+ {
1649
+ type: 'button',
1650
+ className: 'cb-btn',
1651
+ style: { padding: '2px 8px', fontSize: '11px' },
1652
+ onClick: () => setConfirmReset(false),
1653
+ },
1654
+ t('stats.cancel_reset')
1655
+ )
1656
+ )
1657
+ : React.createElement(
1658
+ 'button',
1659
+ {
1660
+ type: 'button',
1661
+ className: 'cb-btn',
1662
+ style: { padding: '2px 8px', fontSize: '11px' },
1663
+ disabled: !!busy,
1664
+ onClick: () => setConfirmReset(true),
1665
+ },
1666
+ t('stats.reset_btn')
1667
+ )
1668
+ )
1669
+ ),
1358
1670
  React.createElement('div', { className: 'cb-section-desc' }, t('stats.desc')),
1359
1671
  React.createElement(
1360
1672
  'div',
1361
- { className: 'cb-grid-2' },
1673
+ { className: 'cb-grid-2', style: { marginTop: '12px' } },
1362
1674
  React.createElement(
1363
1675
  'div',
1364
1676
  { className: 'cb-stat-box' },
1365
- React.createElement('div', { className: 'cb-stat-val' }, `${status.sessionStats?.successfulRequests || 0} / ${status.sessionStats?.totalRequests || 0}`),
1677
+ React.createElement('div', { className: 'cb-stat-val' }, `${s.requests || 0} (${sess.successfulRequests || 0} sess)`),
1366
1678
  React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.requests'))
1367
1679
  ),
1368
1680
  React.createElement(
1369
1681
  'div',
1370
1682
  { className: 'cb-stat-box' },
1371
- React.createElement('div', { className: 'cb-stat-val' }, `~${status.sessionStats?.totalTokensEst || 0}`),
1683
+ React.createElement('div', { className: 'cb-stat-val' }, `${Number(s.totalTokens || 0).toLocaleString()}`),
1372
1684
  React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.tokens'))
1373
1685
  ),
1374
1686
  React.createElement(
1375
1687
  'div',
1376
1688
  { className: 'cb-stat-box' },
1377
- React.createElement('div', { className: 'cb-stat-val' }, status.sessionStats?.lastLatencyMs ? `${status.sessionStats.lastLatencyMs} ms` : '—'),
1378
- React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.latency'))
1689
+ React.createElement('div', { className: 'cb-stat-val' }, `${s.errors429 || 0}`),
1690
+ React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.errors429'))
1379
1691
  ),
1380
1692
  React.createElement(
1381
1693
  'div',
1382
1694
  { className: 'cb-stat-box' },
1383
- React.createElement('div', { className: 'cb-stat-val' }, status.sessionStats?.lastRequestAt ? new Date(status.sessionStats.lastRequestAt).toLocaleTimeString() : '—'),
1384
- React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.last_req'))
1695
+ React.createElement('div', { className: 'cb-stat-val' }, sess.lastLatencyMs ? `${sess.lastLatencyMs} ms` : '—'),
1696
+ React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.latency'))
1385
1697
  )
1386
- )
1698
+ ),
1699
+ topModels.length > 0
1700
+ ? React.createElement(
1701
+ 'div',
1702
+ { style: { marginTop: '14px' } },
1703
+ React.createElement('div', { style: { fontSize: '12px', fontWeight: 500, marginBottom: '6px', color: 'var(--dsw-alias-label-secondary)' } }, t('stats.top_models')),
1704
+ React.createElement(
1705
+ 'div',
1706
+ { style: { display: 'flex', flexDirection: 'column', gap: '4px' } },
1707
+ topModels.map(([modId, data]) =>
1708
+ React.createElement(
1709
+ 'div',
1710
+ {
1711
+ key: modId,
1712
+ style: {
1713
+ display: 'flex',
1714
+ justifyContent: 'space-between',
1715
+ fontSize: '11px',
1716
+ padding: '4px 8px',
1717
+ background: 'var(--dsw-alias-bg-layer-2)',
1718
+ borderRadius: '4px',
1719
+ },
1720
+ },
1721
+ React.createElement('span', { style: { fontFamily: 'monospace' } }, modId),
1722
+ React.createElement('span', null, `${data.requests || 0} reqs · ${Number(data.tokens || 0).toLocaleString()} tok`)
1723
+ )
1724
+ )
1725
+ )
1726
+ )
1727
+ : null
1387
1728
  )
1388
1729
  }
1389
1730
 
@@ -1469,14 +1810,8 @@ function SettingsPage(props) {
1469
1810
  const [smokeResult, setSmokeResult] = React.useState(null)
1470
1811
 
1471
1812
  const [updateState, setUpdateState] = React.useState({
1472
- checking: false,
1473
- updating: false,
1474
- currentVersion: '',
1475
- latestVersion: '',
1476
- updateAvailable: false,
1477
- canAutoUpdate: true,
1478
- error: '',
1479
- notice: '',
1813
+ checking: false, updating: false, currentVersion: '', latestVersion: '',
1814
+ updateAvailable: false, canAutoUpdate: true, error: '', notice: '',
1480
1815
  })
1481
1816
 
1482
1817
  const [apiKeyInput, setApiKeyInput] = React.useState('')
@@ -1486,10 +1821,14 @@ function SettingsPage(props) {
1486
1821
 
1487
1822
  const load = React.useCallback(async () => {
1488
1823
  setErr('')
1489
- const res = await fetch(`${ROUTE_PREFIX}/status`, { cache: 'no-store' })
1824
+ const [res, statsRes] = await Promise.all([
1825
+ fetch(`${ROUTE_PREFIX}/status`, { cache: 'no-store' }),
1826
+ fetch(`${ROUTE_PREFIX}/stats`, { cache: 'no-store' }).catch(() => null),
1827
+ ])
1490
1828
  const data = await res.json().catch(() => ({}))
1491
1829
  if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
1492
- setStatus(data)
1830
+ const statsData = statsRes && statsRes.ok ? await statsRes.json().catch(() => null) : null
1831
+ setStatus({ ...data, statsSummary: statsData || data.statsSummary })
1493
1832
  setDraft(data.config || {})
1494
1833
  }, [])
1495
1834
 
@@ -1718,6 +2057,28 @@ function SettingsPage(props) {
1718
2057
  })
1719
2058
  }
1720
2059
 
2060
+ async function handleConfigPatch(patch) {
2061
+ await performAction('patch-config', async () => {
2062
+ const res = await fetch(`${ROUTE_PREFIX}/config`, {
2063
+ method: 'POST',
2064
+ headers: { 'Content-Type': 'application/json' },
2065
+ body: JSON.stringify(patch),
2066
+ })
2067
+ const data = await res.json().catch(() => ({}))
2068
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
2069
+ await load()
2070
+ })
2071
+ }
2072
+
2073
+ async function handleResetStats() {
2074
+ await performAction('reset-stats', async () => {
2075
+ const res = await fetch(`${ROUTE_PREFIX}/stats/reset`, { method: 'POST' })
2076
+ const data = await res.json().catch(() => ({}))
2077
+ if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
2078
+ await load()
2079
+ })
2080
+ }
2081
+
1721
2082
  const debounceTimerRef = typeof React.useRef === 'function' ? React.useRef(null) : { current: null }
1722
2083
 
1723
2084
  React.useEffect(() => {
@@ -1787,19 +2148,7 @@ function SettingsPage(props) {
1787
2148
  'div',
1788
2149
  { className: 'cb-page' },
1789
2150
  React.createElement('div', { className: 'cb-alert cb-alert-err' }, `${t('settings.loading')}: ${err}`),
1790
- React.createElement(
1791
- 'button',
1792
- {
1793
- type: 'button',
1794
- className: 'cb-btn',
1795
- style: { marginTop: '12px', alignSelf: 'flex-start' },
1796
- onClick: () => {
1797
- setErr('')
1798
- load().catch((e) => setErr(String(e.message || e)))
1799
- },
1800
- },
1801
- t('settings.retry')
1802
- )
2151
+ React.createElement('button', { type: 'button', className: 'cb-btn', style: { marginTop: '12px' }, onClick: () => { setErr(''); load().catch((e) => setErr(String(e.message || e))) } }, t('settings.retry'))
1803
2152
  )
1804
2153
  }
1805
2154
  return React.createElement('div', { className: 'cb-page' }, t('settings.loading'))
@@ -1898,11 +2247,20 @@ function SettingsPage(props) {
1898
2247
  planSynced: !!(status?.config?.planSynced || (status?.config?.dynamicModels && status.config.dynamicModels.length > 0)),
1899
2248
  planSyncedAt: status?.config?.planSyncedAt || 0,
1900
2249
  defaultModelWarning: status?.config?.defaultModelWarning || '',
2250
+ customModels: draft?.customModels || [],
2251
+ modelReasoningDefaults: draft?.modelReasoningDefaults || {},
2252
+ onConfigUpdate: handleConfigPatch,
2253
+ onUpdateReasoningEffort: (modelId, effort) => {
2254
+ const next = { ...(draft?.modelReasoningDefaults || {}) }
2255
+ if (!effort) delete next[modelId]
2256
+ else next[modelId] = effort
2257
+ handleConfigPatch({ modelReasoningDefaults: next })
2258
+ },
1901
2259
  t,
1902
2260
  }),
1903
2261
 
1904
2262
  // Card 4: Session Metrics & Usage Tracking
1905
- React.createElement(StatsSection, { status, t }),
2263
+ React.createElement(StatsSection, { status, onResetStats: handleResetStats, busy, t }),
1906
2264
 
1907
2265
  // Card 5: Diagnostics & Sync with DSH
1908
2266
  React.createElement(DiagSection, {