@goodandready/dsh-clinebot 0.5.20 → 0.5.22

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,25 @@ 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.22] - 2026-10-13
9
+
10
+ ### Added
11
+ - **Per-Model Account Routing (`modelRoutes`) (Issue #218)**: Map specific models or patterns to dedicated accounts in the pool (e.g. `deepseek-v4-pro -> KEY_PRO`) with automatic graceful fallback when the assigned account is in cooldown or unavailable. Added dedicated management UI in Accounts tab with route badges and model selector.
12
+ - **Reasoning Tokens & Detailed Telemetry (`reasoningTokens`, CSV Export) (Issue #218)**: Full tracking of reasoning tokens (`reasoning_tokens`, `completion_tokens_details.reasoning_tokens`) across streaming SSE and non-streaming proxy responses in totals, byModel, and byDay dimensions. Added `GET /dsh-clinebot/stats/export` for complete CSV export and added reasoning token breakdown to Settings stats overview and `/cline stats`.
13
+ - **Model Speed Benchmark / TTFT Latency Probe (Issue #218)**: Added `POST /dsh-clinebot/models/probe` endpoint to benchmark real TTFT latency for any specific model on demand with visual latency badges and trigger buttons in the Models table.
14
+ - **Chat UX & `/cline` Enhancements (Issue #218)**: Added `/cline account <KEY_NAME>` and `/cline use <KEY_NAME>` aliases to switch active account directly from chat, enabled `/cline ping [model]` to probe specific model latency, and added reasoning tokens breakdown to `/cline stats`.
15
+
16
+ ## [0.5.21] - 2026-10-12
17
+
18
+ ### Added
19
+ - **Configurable Model Max Output Tokens (maxTokens) & Form Controls (Issue #216, GH #13)**: Exposed editable `maxTokens` (Max Output Tokens) in the Models settings UI alongside context window, complete with preset Default/Original buttons, input controls, and badge status displays. Added context length and max output token configuration fields to the Custom Models form.
20
+ - **Wildcard Override Defaults and Apply-to-All Action (Issue #216, GH #13)**: Implemented wildcard model context override (`modelId: '*'`) in `getAllModels` and an "All" action in the settings UI calling `/dsh-clinebot/models/context` (`mode: 'apply-all'`) to batch-apply preferred context window and max output tokens across all models.
21
+ - **Authentic Vendor Model Limits (Issue #216, GH #13)**: Added accurate `originalMaxTokens` specifications across models (Qwen 131072, GLM 128000, MiniMax 128000, Kimi 65536, DeepSeek-V4.1 65536, MiMo 16384) and updated original context windows for DeepSeek-V4.1 and GLM-5.3.
22
+
23
+ ### Fixed
24
+ - **Preservation of Manual Provider Token Limits Across Sync (Issue #216, GH #13)**: `buildPiAiProvider` and `upsertPiAiProvider` now query existing provider models from `llm-pi-ai.providers.clinebot.models` and preserve user edits to `contextWindow` and `maxTokens` during dynamic catalog synchronization when not explicitly overridden by plugin configuration.
25
+ - **Context Override Detection for Output Limits (Issue #216, GH #13)**: Updated `isContextOverridden` calculation to consider custom `maxTokens` deviations (`activeMaxTokens !== defaultMaxTokens`), properly reflecting override badges in the UI.
26
+
8
27
  ## [0.5.20] - 2026-10-11
9
28
 
10
29
  ### Fixed
package/lib/client.js CHANGED
@@ -86,6 +86,11 @@ const en = {
86
86
  'accounts.mode_label': 'Pool Strategy',
87
87
  'accounts.mode_least_used': 'Least Used (Quota)',
88
88
  'accounts.mode_round_robin': 'Round Robin (Sequential)',
89
+ 'accounts.routes_title': 'Per-Model Account Routing',
90
+ 'accounts.routes_desc': 'Optionally route specific models to designated accounts in the pool.',
91
+ 'accounts.route_model_placeholder': 'Model ID (e.g. cline-pass/deepseek-v4-pro)',
92
+ 'accounts.route_account_placeholder': 'Target Account',
93
+ 'accounts.route_btn_add': 'Add Route',
89
94
  'quota.title': '📊 ClinePass Quota & Rate Limits',
90
95
  'quota.desc': 'Official rolling window request limits from ClinePass',
91
96
  'quota.account': 'Account: {email} · Plan: {plan}',
@@ -142,23 +147,34 @@ const en = {
142
147
  'models.th_id': 'Model ID',
143
148
  'models.th_ctx': 'Context',
144
149
  'models.th_caps': 'Capabilities',
150
+ 'models.th_route': 'Account Route',
151
+ 'models.route_auto': 'Auto / Pool',
152
+ 'models.btn_probe': 'Ping',
145
153
  'models.star': 'Star',
146
154
  'models.auto_new': 'New',
147
155
  'models.apply_original_all': '⚡ Original Specs',
148
156
  'models.reset_contexts_all': '↺ Reset Contexts',
149
157
  'models.context_updated_msg': 'Model context limits updated and synchronized to DSH.',
150
- 'models.ctx_default': 'Default ({ctx})',
151
- 'models.ctx_original': 'Original ({ctx} · {provider})',
152
- 'models.ctx_edit': 'Edit Context Window',
158
+ 'models.ctx_default': 'Default ({ctx} / {max})',
159
+ 'models.ctx_original': 'Original ({ctx} / {max} · {provider})',
160
+ 'models.ctx_edit': 'Edit Limits',
161
+ 'models.ctx_window_label': 'Context (e.g. 200000)',
162
+ 'models.ctx_max_tokens_label': 'Max Out (e.g. 16384)',
163
+ 'models.ctx_apply_all': 'All',
164
+ 'models.ctx_apply_all_title': 'Apply these limits across all models',
153
165
  'models.ctx_custom_placeholder': 'Tokens (e.g. 2000000)',
154
166
  'models.ctx_save': 'Save',
155
167
  'models.ctx_cancel': 'Cancel',
168
+ 'models.custom_ctx_label': 'Context',
169
+ 'models.custom_max_label': 'Max Out',
156
170
  'models.ctx_tag_original': 'Original',
157
171
  'models.ctx_tag_custom': 'Custom',
158
172
  'stats.title': '📈 Current DSH Session Telemetry',
159
173
  'stats.desc': 'Local monitoring of requests, network latency, and estimated token usage via ClinePass.',
160
174
  'stats.requests': 'Successful / Total Requests',
161
175
  'stats.tokens': 'Estimated Session Tokens',
176
+ 'stats.reasoning_tokens': 'Reasoning Tokens (Thinking)',
177
+ 'stats.export_csv': 'Export CSV',
162
178
  'stats.latency': 'Last Request Latency',
163
179
  'stats.last_req': 'Last Request Time',
164
180
  'models.custom_add_title': 'Custom Models',
@@ -243,6 +259,11 @@ const en = {
243
259
  'accounts.mode_label': '调度策略',
244
260
  'accounts.mode_least_used': '低占用优先 (Least Used)',
245
261
  'accounts.mode_round_robin': '顺序轮询 (Round Robin)',
262
+ 'accounts.routes_title': '特定模型专属路由 (Per-Model)',
263
+ 'accounts.routes_desc': '可针对特定模型指定绑定的账号凭据(如将昂贵模型指向特定账号)。',
264
+ 'accounts.route_model_placeholder': '模型 ID (例如 cline-pass/deepseek-v4-pro)',
265
+ 'accounts.route_account_placeholder': '目标账号',
266
+ 'accounts.route_btn_add': '添加路由',
246
267
  'quota.title': '📊 ClinePass 用量配额与限额监控',
247
268
  'quota.desc': '来自 ClinePass 官方的实时滚动窗口用量限额',
248
269
  'quota.account': '账号:{email} · 套餐:{plan}',
@@ -299,23 +320,34 @@ const en = {
299
320
  'models.th_id': '模型标识符 (ID)',
300
321
  'models.th_ctx': '上下文窗口',
301
322
  'models.th_caps': '特性能力',
323
+ 'models.th_route': '指定路由账号',
324
+ 'models.route_auto': '自动 / 默认池',
325
+ 'models.btn_probe': '测试延迟',
302
326
  'models.star': '推荐',
303
327
  'models.auto_new': '新上线',
304
328
  'models.apply_original_all': '⚡ 原厂规格',
305
329
  'models.reset_contexts_all': '↺ 重置上下文',
306
330
  'models.context_updated_msg': '模型上下文限制已更新并同步生效至 DSH。',
307
- 'models.ctx_default': '默认 ({ctx})',
308
- 'models.ctx_original': '原厂 ({ctx} · {provider})',
309
- 'models.ctx_edit': '编辑上下文窗口',
331
+ 'models.ctx_default': '默认 ({ctx} / {max})',
332
+ 'models.ctx_original': '原厂 ({ctx} / {max} · {provider})',
333
+ 'models.ctx_edit': '编辑限制',
334
+ 'models.ctx_window_label': '上下文 (如 200000)',
335
+ 'models.ctx_max_tokens_label': '最大输出 (如 16384)',
336
+ 'models.ctx_apply_all': '全部',
337
+ 'models.ctx_apply_all_title': '将此限制批量应用到所有模型',
310
338
  'models.ctx_custom_placeholder': 'Token 数量 (如 2000000)',
311
339
  'models.ctx_save': '保存',
312
340
  'models.ctx_cancel': '取消',
341
+ 'models.custom_ctx_label': '上下文',
342
+ 'models.custom_max_label': '最大输出',
313
343
  'models.ctx_tag_original': '原厂',
314
344
  'models.ctx_tag_custom': '自定义',
315
345
  'stats.title': '📈 当前 DSH 会话请求遥测',
316
346
  'stats.desc': '本地实时监控请求次数、网络延迟以及通过 ClinePass 消耗的估算 Token 数量。',
317
347
  'stats.requests': '成功 / 总请求次数',
318
348
  'stats.tokens': '会话累计 Token 估算',
349
+ 'stats.reasoning_tokens': '深度思考 Token 消耗',
350
+ 'stats.export_csv': '导出 CSV',
319
351
  'stats.latency': '最近一次请求延迟',
320
352
  'stats.last_req': '最近请求时间',
321
353
  'models.custom_add_title': '自定义模型',
@@ -804,7 +836,18 @@ function KeySection({ keyPresent, apiKeyInput, setApiKeyInput, showKey, setShowK
804
836
  }
805
837
 
806
838
  // --- src/client/components/accounts-section.js ---
807
- function AccountsSection({ status, busy, handlePinAccount, handleAddAccount, handleDeleteAccount, accountMode, handleUpdateAccountMode, t }) {
839
+ function AccountsSection({
840
+ status,
841
+ busy,
842
+ handlePinAccount,
843
+ handleAddAccount,
844
+ handleDeleteAccount,
845
+ accountMode,
846
+ handleUpdateAccountMode,
847
+ modelRoutes = {},
848
+ onUpdateModelRoutes,
849
+ t,
850
+ }) {
808
851
  if (!status.accounts || !status.accounts.length) return null
809
852
 
810
853
  const [showAdd, setShowAdd] = React.useState(false)
@@ -813,6 +856,8 @@ function AccountsSection({ status, busy, handlePinAccount, handleAddAccount, han
813
856
  const [newKey, setNewKey] = React.useState('')
814
857
  const [showKey, setShowKey] = React.useState(false)
815
858
  const [deleteSecret, setDeleteSecret] = React.useState(true)
859
+ const [routeModel, setRouteModel] = React.useState('')
860
+ const [routeAccount, setRouteAccount] = React.useState('')
816
861
 
817
862
  function handleOpenAdd() {
818
863
  let max = 1
@@ -1114,7 +1159,112 @@ function AccountsSection({ status, busy, handlePinAccount, handleAddAccount, han
1114
1159
  reason: status.lastRotation.reason,
1115
1160
  })
1116
1161
  )
1117
- : null
1162
+ : null,
1163
+ React.createElement(
1164
+ 'div',
1165
+ { style: { marginTop: '14px', paddingTop: '12px', borderTop: '1px solid var(--dsw-alias-border-l2)' } },
1166
+ React.createElement(
1167
+ 'div',
1168
+ { style: { fontSize: '12px', fontWeight: 600, color: 'var(--dsw-alias-label-primary)', marginBottom: '4px' } },
1169
+ t('accounts.routes_title')
1170
+ ),
1171
+ React.createElement(
1172
+ 'div',
1173
+ { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)', marginBottom: '8px' } },
1174
+ t('accounts.routes_desc')
1175
+ ),
1176
+ React.createElement(
1177
+ 'div',
1178
+ { style: { display: 'flex', gap: '6px', flexWrap: 'wrap', alignItems: 'center' } },
1179
+ React.createElement('input', {
1180
+ type: 'text',
1181
+ className: 'cb-input',
1182
+ style: { flex: '2 1 140px', height: '26px', fontSize: '11px', padding: '0 6px' },
1183
+ placeholder: t('accounts.route_model_placeholder'),
1184
+ value: routeModel,
1185
+ onChange: (e) => setRouteModel(e.target.value),
1186
+ }),
1187
+ React.createElement(
1188
+ 'select',
1189
+ {
1190
+ className: 'cb-select',
1191
+ style: { flex: '1 1 120px', height: '26px', fontSize: '11px', padding: '0 4px' },
1192
+ value: routeAccount,
1193
+ onChange: (e) => setRouteAccount(e.target.value),
1194
+ },
1195
+ React.createElement('option', { value: '' }, t('accounts.route_account_placeholder')),
1196
+ (status.accounts || []).map((acc) => {
1197
+ const keyName = acc.apiKeyEnv || acc.id
1198
+ return React.createElement('option', { key: keyName, value: keyName }, `${acc.label || keyName} (${keyName})`)
1199
+ })
1200
+ ),
1201
+ React.createElement(
1202
+ 'button',
1203
+ {
1204
+ type: 'button',
1205
+ className: 'cb-btn cb-btn-primary',
1206
+ style: { padding: '3px 8px', fontSize: '11px' },
1207
+ disabled: !routeModel.trim() || !routeAccount.trim() || !!busy,
1208
+ onClick: () => {
1209
+ if (typeof onUpdateModelRoutes === 'function') {
1210
+ const next = { ...(modelRoutes || {}), [routeModel.trim()]: routeAccount.trim() }
1211
+ onUpdateModelRoutes(next)
1212
+ setRouteModel('')
1213
+ setRouteAccount('')
1214
+ }
1215
+ },
1216
+ },
1217
+ t('accounts.route_btn_add')
1218
+ )
1219
+ ),
1220
+ Object.keys(modelRoutes || {}).length > 0
1221
+ ? React.createElement(
1222
+ 'div',
1223
+ { style: { display: 'flex', flexDirection: 'column', gap: '4px', marginTop: '8px' } },
1224
+ Object.entries(modelRoutes || {}).map(([mId, accKey]) =>
1225
+ React.createElement(
1226
+ 'div',
1227
+ {
1228
+ key: mId,
1229
+ style: {
1230
+ display: 'flex',
1231
+ justifyContent: 'space-between',
1232
+ alignItems: 'center',
1233
+ padding: '3px 8px',
1234
+ fontSize: '11px',
1235
+ background: 'var(--dsw-alias-bg-layer-2)',
1236
+ borderRadius: '4px',
1237
+ },
1238
+ },
1239
+ React.createElement(
1240
+ 'span',
1241
+ null,
1242
+ React.createElement('code', null, mId),
1243
+ ' ➔ ',
1244
+ React.createElement('strong', null, accKey)
1245
+ ),
1246
+ React.createElement(
1247
+ 'button',
1248
+ {
1249
+ type: 'button',
1250
+ className: 'cb-btn cb-btn-danger',
1251
+ style: { padding: '1px 6px', fontSize: '10px' },
1252
+ disabled: !!busy,
1253
+ onClick: () => {
1254
+ if (typeof onUpdateModelRoutes === 'function') {
1255
+ const next = { ...(modelRoutes || {}) }
1256
+ delete next[mId]
1257
+ onUpdateModelRoutes(next)
1258
+ }
1259
+ },
1260
+ },
1261
+ '✕'
1262
+ )
1263
+ )
1264
+ )
1265
+ )
1266
+ : null
1267
+ )
1118
1268
  )
1119
1269
  }
1120
1270
 
@@ -1219,17 +1369,21 @@ function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, t }
1219
1369
  function CustomModelsSub({ customModels = [], onConfigUpdate, busy, t }) {
1220
1370
  const [newId, setNewId] = React.useState('')
1221
1371
  const [newName, setNewName] = React.useState('')
1372
+ const [newCtx, setNewCtx] = React.useState('')
1373
+ const [newMax, setNewMax] = React.useState('')
1222
1374
  const [isExpanded, setIsExpanded] = React.useState(false)
1223
1375
 
1224
1376
  function handleAdd() {
1225
1377
  const trimmedId = newId.trim()
1226
1378
  const trimmedName = newName.trim() || trimmedId
1227
1379
  if (!trimmedId) return
1380
+ const parsedCtx = parseInt(newCtx, 10)
1381
+ const parsedMax = parseInt(newMax, 10)
1228
1382
  const nextList = [...customModels.filter((m) => m.id !== trimmedId), {
1229
1383
  id: trimmedId,
1230
1384
  name: trimmedName,
1231
- contextLength: 128000,
1232
- maxTokens: 8192,
1385
+ contextLength: parsedCtx > 0 ? parsedCtx : 128000,
1386
+ maxTokens: parsedMax > 0 ? parsedMax : 8192,
1233
1387
  category: 'general',
1234
1388
  isCustom: true,
1235
1389
  }]
@@ -1238,6 +1392,8 @@ function CustomModelsSub({ customModels = [], onConfigUpdate, busy, t }) {
1238
1392
  }
1239
1393
  setNewId('')
1240
1394
  setNewName('')
1395
+ setNewCtx('')
1396
+ setNewMax('')
1241
1397
  }
1242
1398
 
1243
1399
  function handleDelete(id) {
@@ -1288,7 +1444,7 @@ function CustomModelsSub({ customModels = [], onConfigUpdate, busy, t }) {
1288
1444
  React.createElement('input', {
1289
1445
  type: 'text',
1290
1446
  className: 'cb-input',
1291
- style: { flex: '1 1 180px' },
1447
+ style: { flex: '2 1 140px' },
1292
1448
  placeholder: t('models.custom_id'),
1293
1449
  value: newId,
1294
1450
  disabled: !!busy,
@@ -1297,12 +1453,32 @@ function CustomModelsSub({ customModels = [], onConfigUpdate, busy, t }) {
1297
1453
  React.createElement('input', {
1298
1454
  type: 'text',
1299
1455
  className: 'cb-input',
1300
- style: { flex: '1 1 180px' },
1456
+ style: { flex: '2 1 140px' },
1301
1457
  placeholder: t('models.custom_name'),
1302
1458
  value: newName,
1303
1459
  disabled: !!busy,
1304
1460
  onChange: (e) => setNewName(e.target.value),
1305
1461
  }),
1462
+ React.createElement('input', {
1463
+ type: 'number',
1464
+ className: 'cb-input',
1465
+ style: { flex: '1 1 90px' },
1466
+ placeholder: t('models.custom_ctx_label'),
1467
+ title: t('models.custom_ctx_label'),
1468
+ value: newCtx,
1469
+ disabled: !!busy,
1470
+ onChange: (e) => setNewCtx(e.target.value),
1471
+ }),
1472
+ React.createElement('input', {
1473
+ type: 'number',
1474
+ className: 'cb-input',
1475
+ style: { flex: '1 1 80px' },
1476
+ placeholder: t('models.custom_max_label'),
1477
+ title: t('models.custom_max_label'),
1478
+ value: newMax,
1479
+ disabled: !!busy,
1480
+ onChange: (e) => setNewMax(e.target.value),
1481
+ }),
1306
1482
  React.createElement(
1307
1483
  'button',
1308
1484
  {
@@ -1338,7 +1514,8 @@ function CustomModelsSub({ customModels = [], onConfigUpdate, busy, t }) {
1338
1514
  'div',
1339
1515
  null,
1340
1516
  React.createElement('span', { style: { fontWeight: 500 } }, cm.name || cm.id),
1341
- React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)', marginLeft: '8px' } }, `(${cm.id})`)
1517
+ React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)', marginLeft: '8px' } }, `(${cm.id})`),
1518
+ React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)', marginLeft: '8px', fontSize: '11px' } }, `[${cm.contextLength || 128000} / ${cm.maxTokens || 8192}]`)
1342
1519
  ),
1343
1520
  React.createElement(
1344
1521
  'button',
@@ -1385,6 +1562,7 @@ function ModelsSection({
1385
1562
  const [viewFilter, setViewFilter] = React.useState('all')
1386
1563
  const [editingModelId, setEditingModelId] = React.useState(null)
1387
1564
  const [customCtxVal, setCustomCtxVal] = React.useState('')
1565
+ const [customMaxVal, setCustomMaxVal] = React.useState('')
1388
1566
 
1389
1567
  function formatCtx(num) {
1390
1568
  num = Number(num) || 200000
@@ -1580,9 +1758,11 @@ function ModelsSection({
1580
1758
  const isEnabled = !disabledSet.has(m.id)
1581
1759
  const defaultCtx = m.defaultContextLength || m.clineContextLength || 200000
1582
1760
  const origCtx = m.originalContextLength || 200000
1761
+ const defaultMax = m.defaultMaxTokens || 8192
1762
+ const origMax = m.originalMaxTokens || defaultMax
1583
1763
  const origProv = m.originalProvider || 'Original'
1584
1764
  const isOverridden = Boolean(m.isContextOverridden)
1585
- const isOriginal = isOverridden && (m.contextLength === origCtx)
1765
+ const isOriginal = isOverridden && (m.contextLength === origCtx && (m.maxTokens || defaultMax) === origMax)
1586
1766
 
1587
1767
  return React.createElement(
1588
1768
  'tr',
@@ -1641,7 +1821,7 @@ function ModelsSection({
1641
1821
  setEditingModelId(null)
1642
1822
  },
1643
1823
  },
1644
- t('models.ctx_default', { ctx: formatCtx(defaultCtx) })
1824
+ t('models.ctx_default', { ctx: formatCtx(defaultCtx), max: formatCtx(defaultMax) })
1645
1825
  ),
1646
1826
  React.createElement(
1647
1827
  'button',
@@ -1651,41 +1831,76 @@ function ModelsSection({
1651
1831
  style: { padding: '2px 6px', fontSize: '11px' },
1652
1832
  onClick: () => {
1653
1833
  if (typeof handleUpdateModelContext === 'function') {
1654
- handleUpdateModelContext({ modelId: m.id, contextLength: origCtx })
1834
+ handleUpdateModelContext({ modelId: m.id, contextLength: origCtx, maxTokens: origMax })
1655
1835
  }
1656
1836
  setEditingModelId(null)
1657
1837
  },
1658
1838
  },
1659
- t('models.ctx_original', { ctx: formatCtx(origCtx), provider: origProv })
1839
+ t('models.ctx_original', { ctx: formatCtx(origCtx), max: formatCtx(origMax), provider: origProv })
1660
1840
  )
1661
1841
  ),
1662
1842
  React.createElement(
1663
1843
  'div',
1664
- { style: { display: 'flex', gap: '4px', alignItems: 'center' } },
1844
+ { style: { display: 'flex', gap: '4px', alignItems: 'center', flexWrap: 'wrap' } },
1665
1845
  React.createElement('input', {
1666
1846
  type: 'number',
1667
1847
  className: 'cb-input',
1668
- style: { height: '24px', fontSize: '11px', padding: '0 6px', width: '120px' },
1669
- placeholder: t('models.ctx_custom_placeholder'),
1848
+ style: { height: '24px', fontSize: '11px', padding: '0 6px', width: '90px' },
1849
+ placeholder: t('models.ctx_window_label'),
1850
+ title: t('models.ctx_window_label'),
1670
1851
  value: customCtxVal,
1671
1852
  onChange: (e) => setCustomCtxVal(e.target.value),
1672
1853
  }),
1854
+ React.createElement('input', {
1855
+ type: 'number',
1856
+ className: 'cb-input',
1857
+ style: { height: '24px', fontSize: '11px', padding: '0 6px', width: '75px' },
1858
+ placeholder: t('models.ctx_max_tokens_label'),
1859
+ title: t('models.ctx_max_tokens_label'),
1860
+ value: customMaxVal,
1861
+ onChange: (e) => setCustomMaxVal(e.target.value),
1862
+ }),
1673
1863
  React.createElement(
1674
1864
  'button',
1675
1865
  {
1676
1866
  type: 'button',
1677
1867
  className: 'cb-btn cb-btn-primary',
1678
- style: { padding: '2px 8px', fontSize: '11px' },
1868
+ style: { padding: '2px 6px', fontSize: '11px' },
1679
1869
  onClick: () => {
1680
- const val = parseInt(customCtxVal, 10)
1681
- if (val > 0 && typeof handleUpdateModelContext === 'function') {
1682
- handleUpdateModelContext({ modelId: m.id, contextLength: val })
1870
+ const cVal = parseInt(customCtxVal, 10)
1871
+ const mVal = parseInt(customMaxVal, 10)
1872
+ if (typeof handleUpdateModelContext === 'function') {
1873
+ const payload = { modelId: m.id }
1874
+ if (cVal > 0) payload.contextLength = cVal
1875
+ if (mVal > 0) payload.maxTokens = mVal
1876
+ handleUpdateModelContext(payload)
1683
1877
  }
1684
1878
  setEditingModelId(null)
1685
1879
  },
1686
1880
  },
1687
1881
  t('models.ctx_save')
1688
1882
  ),
1883
+ React.createElement(
1884
+ 'button',
1885
+ {
1886
+ type: 'button',
1887
+ className: 'cb-btn',
1888
+ title: t('models.ctx_apply_all_title'),
1889
+ style: { padding: '2px 5px', fontSize: '11px' },
1890
+ onClick: () => {
1891
+ const cVal = parseInt(customCtxVal, 10)
1892
+ const mVal = parseInt(customMaxVal, 10)
1893
+ if (typeof handleUpdateModelContext === 'function') {
1894
+ const payload = { mode: 'apply-all' }
1895
+ if (cVal > 0) payload.contextLength = cVal
1896
+ if (mVal > 0) payload.maxTokens = mVal
1897
+ handleUpdateModelContext(payload)
1898
+ }
1899
+ setEditingModelId(null)
1900
+ },
1901
+ },
1902
+ t('models.ctx_apply_all')
1903
+ ),
1689
1904
  React.createElement(
1690
1905
  'button',
1691
1906
  {
@@ -1707,10 +1922,11 @@ function ModelsSection({
1707
1922
  style: { fontWeight: '600', cursor: 'pointer' },
1708
1923
  onClick: () => {
1709
1924
  setCustomCtxVal(String(m.contextLength || defaultCtx))
1925
+ setCustomMaxVal(String(m.maxTokens || defaultMax))
1710
1926
  setEditingModelId(m.id)
1711
1927
  },
1712
1928
  },
1713
- formatCtx(m.contextLength || defaultCtx)
1929
+ `${formatCtx(m.contextLength || defaultCtx)} / ${formatCtx(m.maxTokens || defaultMax)}`
1714
1930
  ),
1715
1931
  isOverridden
1716
1932
  ? React.createElement(
@@ -1720,6 +1936,7 @@ function ModelsSection({
1720
1936
  style: { fontSize: '10px', padding: '1px 6px', cursor: 'pointer' },
1721
1937
  onClick: () => {
1722
1938
  setCustomCtxVal(String(m.contextLength || defaultCtx))
1939
+ setCustomMaxVal(String(m.maxTokens || defaultMax))
1723
1940
  setEditingModelId(m.id)
1724
1941
  },
1725
1942
  },
@@ -1735,6 +1952,7 @@ function ModelsSection({
1735
1952
  title: t('models.ctx_edit'),
1736
1953
  onClick: () => {
1737
1954
  setCustomCtxVal(String(m.contextLength || defaultCtx))
1955
+ setCustomMaxVal(String(m.maxTokens || defaultMax))
1738
1956
  setEditingModelId(m.id)
1739
1957
  },
1740
1958
  },
@@ -1835,7 +2053,17 @@ function StatsSection({ status, onResetStats, busy, t }) {
1835
2053
  React.createElement('div', { className: 'cb-section-title' }, t('stats.title')),
1836
2054
  React.createElement(
1837
2055
  'div',
1838
- null,
2056
+ { style: { display: 'flex', gap: '6px', alignItems: 'center' } },
2057
+ React.createElement(
2058
+ 'a',
2059
+ {
2060
+ href: '/dsh-clinebot/stats/export',
2061
+ download: 'clinebot-stats.csv',
2062
+ className: 'cb-btn',
2063
+ style: { padding: '2px 8px', fontSize: '11px', textDecoration: 'none', display: 'inline-flex', alignItems: 'center' },
2064
+ },
2065
+ `📥 ${t('stats.export_csv')}`
2066
+ ),
1839
2067
  confirmReset
1840
2068
  ? React.createElement(
1841
2069
  React.Fragment,
@@ -1891,8 +2119,18 @@ function StatsSection({ status, onResetStats, busy, t }) {
1891
2119
  React.createElement(
1892
2120
  'div',
1893
2121
  { className: 'cb-stat-box' },
1894
- React.createElement('div', { className: 'cb-stat-val' }, `${Number(totalTokens).toLocaleString()}`),
1895
- React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.tokens'))
2122
+ React.createElement(
2123
+ 'div',
2124
+ { className: 'cb-stat-val' },
2125
+ `${Number(totalTokens).toLocaleString()}`
2126
+ ),
2127
+ React.createElement(
2128
+ 'div',
2129
+ { className: 'cb-stat-lbl' },
2130
+ totals.reasoningTokens || sess.reasoningTokensEst
2131
+ ? `${t('stats.tokens')} (${Number(totals.reasoningTokens || sess.reasoningTokensEst).toLocaleString()} reasoning)`
2132
+ : t('stats.tokens')
2133
+ )
1896
2134
  ),
1897
2135
  React.createElement(
1898
2136
  'div',
@@ -1930,7 +2168,7 @@ function StatsSection({ status, onResetStats, busy, t }) {
1930
2168
  },
1931
2169
  },
1932
2170
  React.createElement('span', { style: { fontFamily: 'monospace' } }, modId),
1933
- React.createElement('span', null, `${data.requests || 0} reqs · ${Number(data.totalTokens ?? data.tokens ?? 0).toLocaleString()} tok`)
2171
+ React.createElement('span', null, `${data.requests || 0} reqs · ${Number(data.totalTokens ?? data.tokens ?? 0).toLocaleString()} tok${data.reasoningTokens ? ` (${Number(data.reasoningTokens).toLocaleString()} thk)` : ''}`)
1934
2172
  )
1935
2173
  )
1936
2174
  )
@@ -2498,6 +2736,8 @@ function SettingsPage(props) {
2498
2736
  status, busy, handlePinAccount, handleAddAccount, handleDeleteAccount,
2499
2737
  accountMode: draft?.accountMode || status?.config?.accountMode || 'least_used',
2500
2738
  handleUpdateAccountMode: (mode) => handleConfigPatch({ accountMode: mode }),
2739
+ modelRoutes: draft?.modelRoutes || status?.config?.modelRoutes || {},
2740
+ onUpdateModelRoutes: (routes) => handleConfigPatch({ modelRoutes: routes }),
2501
2741
  t,
2502
2742
  }),
2503
2743
 
@@ -490,10 +490,15 @@ export function buildPiAiProvider({
490
490
  modelContextOverrides = [],
491
491
  models = [],
492
492
  customModels = [],
493
+ existingModels = [],
493
494
  displayName = PROVIDER_DISPLAY_NAME,
494
495
  } = {}) {
495
496
  const allAvailable = getAllModels(dynamicModels, modelContextOverrides, customModels)
496
497
  const customModelsMap = new Map((Array.isArray(customModels) ? customModels : []).filter(Boolean).map(cm => [cm.id, cm]))
498
+ const existingMap = new Map((Array.isArray(existingModels) ? existingModels : []).filter(Boolean).map(m => [m.id, m]))
499
+ const explicitOverridesSet = new Set((Array.isArray(modelContextOverrides) ? modelContextOverrides : []).map(o => o?.modelId))
500
+ const hasWildcardOverride = explicitOverridesSet.has('*')
501
+
497
502
  const modelList = (Array.isArray(models) && models.length ? models : allAvailable).map((m) => {
498
503
  let item
499
504
  if (typeof m === 'string') {
@@ -504,12 +509,26 @@ export function buildPiAiProvider({
504
509
  item = m
505
510
  }
506
511
  const hasImage = item.input?.includes('image') || item.input?.includes('vision')
512
+
513
+ // Preserve manual provider edits if user configured contextWindow or maxTokens in llm-pi-ai without explicit override in clinebot
514
+ const existingEntry = existingMap.get(item.id)
515
+ let ctxWin = Number(item.contextLength || item.contextWindow) || 200000
516
+ let maxTok = Number(item.maxTokens) || 8192
517
+ if (existingEntry && !explicitOverridesSet.has(item.id) && !hasWildcardOverride) {
518
+ if (typeof existingEntry.contextWindow === 'number' && existingEntry.contextWindow > 0) {
519
+ ctxWin = existingEntry.contextWindow
520
+ }
521
+ if (typeof existingEntry.maxTokens === 'number' && existingEntry.maxTokens > 0) {
522
+ maxTok = existingEntry.maxTokens
523
+ }
524
+ }
525
+
507
526
  const res = {
508
527
  id: item.id,
509
528
  name: item.name || item.id,
510
529
  description: formatModelDescription(item),
511
- contextWindow: Number(item.contextLength || item.contextWindow) || 200000,
512
- maxTokens: Number(item.maxTokens) || 8192,
530
+ contextWindow: ctxWin,
531
+ maxTokens: maxTok,
513
532
  input: hasImage ? ['text', 'image'] : ['text'],
514
533
  provider: PROVIDER_ID,
515
534
  }
package/lib/config.js CHANGED
@@ -60,6 +60,8 @@ export const Config = z.object({
60
60
  .description('Custom user-defined model context length and max token overrides.').volatile(),
61
61
  modelReasoningDefaults: z.dict(z.string()).default({})
62
62
  .description('Configured default reasoning effort per model (low, medium, high, max).').volatile(),
63
+ modelRoutes: z.dict(z.string()).default({})
64
+ .description('Per-model target account routing map (e.g. { "deepseek-v4-pro": "KEY_2" }).').volatile(),
63
65
  customModels: z.array(z.object({
64
66
  id: z.string(),
65
67
  name: z.string(),
@@ -168,6 +170,7 @@ export function publicConfig(cfg) {
168
170
  planSyncedAt,
169
171
  modelContextOverrides,
170
172
  modelReasoningDefaults: cfg?.modelReasoningDefaults && typeof cfg.modelReasoningDefaults === 'object' ? cfg.modelReasoningDefaults : {},
173
+ modelRoutes: cfg?.modelRoutes && typeof cfg.modelRoutes === 'object' ? cfg.modelRoutes : {},
171
174
  customModels: custom,
172
175
  proxyMode: cfg?.proxyMode !== false,
173
176
  statsPath: String(cfg?.statsPath || '~/.dsh/clinebot-stats.json'),