@goodandready/dsh-clinebot 0.5.21 → 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,14 @@ 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
+
8
16
  ## [0.5.21] - 2026-10-12
9
17
 
10
18
  ### Added
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,6 +147,9 @@ 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',
@@ -165,6 +173,8 @@ const en = {
165
173
  'stats.desc': 'Local monitoring of requests, network latency, and estimated token usage via ClinePass.',
166
174
  'stats.requests': 'Successful / Total Requests',
167
175
  'stats.tokens': 'Estimated Session Tokens',
176
+ 'stats.reasoning_tokens': 'Reasoning Tokens (Thinking)',
177
+ 'stats.export_csv': 'Export CSV',
168
178
  'stats.latency': 'Last Request Latency',
169
179
  'stats.last_req': 'Last Request Time',
170
180
  'models.custom_add_title': 'Custom Models',
@@ -249,6 +259,11 @@ const en = {
249
259
  'accounts.mode_label': '调度策略',
250
260
  'accounts.mode_least_used': '低占用优先 (Least Used)',
251
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': '添加路由',
252
267
  'quota.title': '📊 ClinePass 用量配额与限额监控',
253
268
  'quota.desc': '来自 ClinePass 官方的实时滚动窗口用量限额',
254
269
  'quota.account': '账号:{email} · 套餐:{plan}',
@@ -305,6 +320,9 @@ const en = {
305
320
  'models.th_id': '模型标识符 (ID)',
306
321
  'models.th_ctx': '上下文窗口',
307
322
  'models.th_caps': '特性能力',
323
+ 'models.th_route': '指定路由账号',
324
+ 'models.route_auto': '自动 / 默认池',
325
+ 'models.btn_probe': '测试延迟',
308
326
  'models.star': '推荐',
309
327
  'models.auto_new': '新上线',
310
328
  'models.apply_original_all': '⚡ 原厂规格',
@@ -328,6 +346,8 @@ const en = {
328
346
  'stats.desc': '本地实时监控请求次数、网络延迟以及通过 ClinePass 消耗的估算 Token 数量。',
329
347
  'stats.requests': '成功 / 总请求次数',
330
348
  'stats.tokens': '会话累计 Token 估算',
349
+ 'stats.reasoning_tokens': '深度思考 Token 消耗',
350
+ 'stats.export_csv': '导出 CSV',
331
351
  'stats.latency': '最近一次请求延迟',
332
352
  'stats.last_req': '最近请求时间',
333
353
  'models.custom_add_title': '自定义模型',
@@ -816,7 +836,18 @@ function KeySection({ keyPresent, apiKeyInput, setApiKeyInput, showKey, setShowK
816
836
  }
817
837
 
818
838
  // --- src/client/components/accounts-section.js ---
819
- 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
+ }) {
820
851
  if (!status.accounts || !status.accounts.length) return null
821
852
 
822
853
  const [showAdd, setShowAdd] = React.useState(false)
@@ -825,6 +856,8 @@ function AccountsSection({ status, busy, handlePinAccount, handleAddAccount, han
825
856
  const [newKey, setNewKey] = React.useState('')
826
857
  const [showKey, setShowKey] = React.useState(false)
827
858
  const [deleteSecret, setDeleteSecret] = React.useState(true)
859
+ const [routeModel, setRouteModel] = React.useState('')
860
+ const [routeAccount, setRouteAccount] = React.useState('')
828
861
 
829
862
  function handleOpenAdd() {
830
863
  let max = 1
@@ -1126,7 +1159,112 @@ function AccountsSection({ status, busy, handlePinAccount, handleAddAccount, han
1126
1159
  reason: status.lastRotation.reason,
1127
1160
  })
1128
1161
  )
1129
- : 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
+ )
1130
1268
  )
1131
1269
  }
1132
1270
 
@@ -1915,7 +2053,17 @@ function StatsSection({ status, onResetStats, busy, t }) {
1915
2053
  React.createElement('div', { className: 'cb-section-title' }, t('stats.title')),
1916
2054
  React.createElement(
1917
2055
  'div',
1918
- 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
+ ),
1919
2067
  confirmReset
1920
2068
  ? React.createElement(
1921
2069
  React.Fragment,
@@ -1971,8 +2119,18 @@ function StatsSection({ status, onResetStats, busy, t }) {
1971
2119
  React.createElement(
1972
2120
  'div',
1973
2121
  { className: 'cb-stat-box' },
1974
- React.createElement('div', { className: 'cb-stat-val' }, `${Number(totalTokens).toLocaleString()}`),
1975
- 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
+ )
1976
2134
  ),
1977
2135
  React.createElement(
1978
2136
  'div',
@@ -2010,7 +2168,7 @@ function StatsSection({ status, onResetStats, busy, t }) {
2010
2168
  },
2011
2169
  },
2012
2170
  React.createElement('span', { style: { fontFamily: 'monospace' } }, modId),
2013
- 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)` : ''}`)
2014
2172
  )
2015
2173
  )
2016
2174
  )
@@ -2578,6 +2736,8 @@ function SettingsPage(props) {
2578
2736
  status, busy, handlePinAccount, handleAddAccount, handleDeleteAccount,
2579
2737
  accountMode: draft?.accountMode || status?.config?.accountMode || 'least_used',
2580
2738
  handleUpdateAccountMode: (mode) => handleConfigPatch({ accountMode: mode }),
2739
+ modelRoutes: draft?.modelRoutes || status?.config?.modelRoutes || {},
2740
+ onUpdateModelRoutes: (routes) => handleConfigPatch({ modelRoutes: routes }),
2581
2741
  t,
2582
2742
  }),
2583
2743
 
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'),
@@ -14,9 +14,9 @@ export const LOCAL_PROXY_KEY_ENV = 'CLINEBOT_LOCAL_PROXY_TOKEN'
14
14
  let cachedToken = null
15
15
 
16
16
  const require = createRequire(import.meta.url)
17
- let packageVersion = '0.5.21'
17
+ let packageVersion = '0.5.22'
18
18
  try {
19
- packageVersion = require('../package.json').version || '0.5.21'
19
+ packageVersion = require('../package.json').version || '0.5.22'
20
20
  } catch (err) {
21
21
  /* fallback to default version if manifest is unreadable */
22
22
  }
@@ -118,3 +118,23 @@ export function sendProxyTerminalError(res, { hadTimeout, is429Exhausted, lastEr
118
118
  },
119
119
  })
120
120
  }
121
+
122
+
123
+ /**
124
+ * Safely extract standard and reasoning token counts from usage object.
125
+ */
126
+ export function extractUsageMetrics(usage) {
127
+ if (!usage || typeof usage !== 'object') {
128
+ return { promptTokens: 0, completionTokens: 0, reasoningTokens: 0, totalTokens: 0 }
129
+ }
130
+ const promptTokens = Number(usage.prompt_tokens) || 0
131
+ const completionTokens = Number(usage.completion_tokens) || 0
132
+ const reasoningTokens = Number(
133
+ usage.completion_tokens_details?.reasoning_tokens ||
134
+ usage.reasoning_tokens ||
135
+ usage.prompt_tokens_details?.reasoning_tokens ||
136
+ 0
137
+ )
138
+ const totalTokens = Number(usage.total_tokens) || (promptTokens + completionTokens)
139
+ return { promptTokens, completionTokens, reasoningTokens, totalTokens }
140
+ }
@@ -3,7 +3,7 @@ import { assertTrustedSettingsRequest } from '../access.js'
3
3
  import { publicConfig, plainConfig, Config } from '../config.js'
4
4
  import { resolveActiveAccountKey, resolvePathWithHome } from '../provider-sync.js'
5
5
  import { getAllModels, saveModelsDiskCache, getOriginalModelContext, getOriginalModelMaxTokens } from '../models.js'
6
- import { fetchUsageLimits } from '../cline-client.js'
6
+ import { fetchUsageLimits, smokeChat } from '../cline-client.js'
7
7
  import { publicUsage } from '../http.js'
8
8
 
9
9
  export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderState }) {
@@ -240,4 +240,38 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
240
240
  }
241
241
  },
242
242
  }), 'dsh-clinebot: /usage')
243
+
244
+ // POST /dsh-clinebot/models/probe — benchmark TTFT latency for specific model
245
+ ctx.effect(() => ctx.webServer.register({
246
+ kind: 'exact',
247
+ path: '/dsh-clinebot/models/probe',
248
+ handler: async (req, res) => {
249
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
250
+ if (!assertTrustedSettingsRequest(req, res)) return
251
+ try {
252
+ const bodyBuf = await readBody(req)
253
+ let body = {}
254
+ try { body = JSON.parse(bodyBuf.toString('utf8')) } catch { body = {} }
255
+ const pub = publicConfig(live())
256
+ const activeKey = await resolveActiveAccountKey(ctx, live())
257
+ if (!activeKey.value) {
258
+ return writeJson(res, 400, { ok: false, error: 'API key not found' })
259
+ }
260
+ const modelId = body.modelId || body.model || pub.defaultModel
261
+ const result = await smokeChat(pub.baseUrl, activeKey.value, {
262
+ model: modelId,
263
+ timeoutMs: Math.min(pub.smokeTimeoutMs || 25000, 15000),
264
+ })
265
+ return writeJson(res, 200, {
266
+ ok: result.ok,
267
+ modelId,
268
+ latencyMs: result.latencyMs,
269
+ status: result.status,
270
+ error: result.error || null,
271
+ })
272
+ } catch (err) {
273
+ return writeJson(res, 500, { ok: false, error: String(err?.message || err) })
274
+ }
275
+ },
276
+ }), 'dsh-clinebot: /models/probe')
243
277
  }
@@ -27,6 +27,7 @@ import {
27
27
  isAuthorizedProxyRequest,
28
28
  USER_AGENT,
29
29
  sendProxyTerminalError,
30
+ extractUsageMetrics,
30
31
  } from '../proxy-token.js'
31
32
 
32
33
  export { hasDisallowedBrowserOrigin, isAuthorizedProxyRequest, USER_AGENT }
@@ -120,7 +121,7 @@ export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncPr
120
121
  return
121
122
  }
122
123
 
123
- const routeResult = resolveSessionAccount(sessionId, pool, pub.activeAccount, pub.accountMode)
124
+ const routeResult = resolveSessionAccount(sessionId, pool, pub.activeAccount, pub.accountMode, body?.model, pub.modelRoutes)
124
125
  const targetAccount = routeResult.account
125
126
  lastTargetAccount = targetAccount
126
127
 
@@ -297,6 +298,7 @@ export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncPr
297
298
 
298
299
  let promptTokens = 0
299
300
  let completionTokens = 0
301
+ let reasoningTokens = 0
300
302
  let totalTokens = 0
301
303
  let buffer = ''
302
304
  let streamError = null
@@ -350,9 +352,11 @@ export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncPr
350
352
  try {
351
353
  const parsed = JSON.parse(trimmed.slice(5).trim())
352
354
  if (parsed.usage) {
353
- promptTokens = parsed.usage.prompt_tokens || promptTokens
354
- completionTokens = parsed.usage.completion_tokens || completionTokens
355
- totalTokens = parsed.usage.total_tokens || totalTokens
355
+ const u = extractUsageMetrics(parsed.usage)
356
+ promptTokens = u.promptTokens || promptTokens
357
+ completionTokens = u.completionTokens || completionTokens
358
+ reasoningTokens = u.reasoningTokens || reasoningTokens
359
+ totalTokens = u.totalTokens || totalTokens
356
360
  }
357
361
  } catch {}
358
362
  }
@@ -419,6 +423,7 @@ export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncPr
419
423
  accountId: targetAccount.id || targetAccount.label,
420
424
  promptTokens,
421
425
  completionTokens,
426
+ reasoningTokens,
422
427
  totalTokens: totalTokens || (promptTokens + completionTokens),
423
428
  isError: isStreamFailed,
424
429
  statsPath: pub.statsPath
@@ -495,12 +500,14 @@ export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncPr
495
500
  }
496
501
 
497
502
  cleanupClientListeners()
503
+ const nonStreamUsage = extractUsageMetrics(resJson?.usage)
498
504
  recordUsage({
499
505
  model: body.model,
500
506
  accountId: targetAccount.id || targetAccount.label,
501
- promptTokens: resJson?.usage?.prompt_tokens || 0,
502
- completionTokens: resJson?.usage?.completion_tokens || 0,
503
- totalTokens: resJson?.usage?.total_tokens || 0,
507
+ promptTokens: nonStreamUsage.promptTokens,
508
+ completionTokens: nonStreamUsage.completionTokens,
509
+ reasoningTokens: nonStreamUsage.reasoningTokens,
510
+ totalTokens: nonStreamUsage.totalTokens,
504
511
  isError: false,
505
512
  statsPath: pub.statsPath
506
513
  })
@@ -582,4 +589,4 @@ export function registerProxyRoutes(ctx, routeEnv) {
582
589
  path: '/dsh-clinebot/v1/models',
583
590
  handler: modelsHandler,
584
591
  }), 'dsh-clinebot: /v1/models')
585
- }
592
+ }
@@ -3,7 +3,7 @@ import { assertTrustedSettingsRequest } from '../access.js'
3
3
  import { publicConfig, plainConfig, Config } from '../config.js'
4
4
  import { buildStatus } from '../provider-sync.js'
5
5
  import { saveCredentialKey, smokeChat, DEFAULT_API_KEY_ENV } from '../cline-client.js'
6
- import { getStatsSummary, resetStats } from '../stats-storage.js'
6
+ import { getStatsSummary, resetStats, exportStatsAsCsv } from '../stats-storage.js'
7
7
 
8
8
  export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProviderState, triggerAutoDiscover }) {
9
9
  // 1. GET /dsh-clinebot/status
@@ -212,4 +212,23 @@ export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProvider
212
212
  }
213
213
  },
214
214
  }), 'dsh-clinebot: /stats/reset')
215
+
216
+ // 6. GET /dsh-clinebot/stats/export — export token telemetry as CSV
217
+ ctx.effect(() => ctx.webServer.register({
218
+ kind: 'exact',
219
+ path: '/dsh-clinebot/stats/export',
220
+ handler: async (req, res) => {
221
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
222
+ if (!assertTrustedSettingsRequest(req, res)) return
223
+ try {
224
+ const csv = exportStatsAsCsv()
225
+ res.statusCode = 200
226
+ res.setHeader('Content-Type', 'text/csv; charset=utf-8')
227
+ res.setHeader('Content-Disposition', 'attachment; filename="clinebot-stats.csv"')
228
+ res.end(csv)
229
+ } catch (err) {
230
+ writeJson(res, 500, { ok: false, error: String(err?.message || err) })
231
+ }
232
+ },
233
+ }), 'dsh-clinebot: /stats/export')
215
234
  }
@@ -188,7 +188,31 @@ export function selectLeastUsedAccount(accounts = []) {
188
188
  * @param {string} [defaultActiveId] Explicit active account ID, envName or 'auto'
189
189
  * @returns {{ account: Object|null, isReassigned: boolean, reason: string }}
190
190
  */
191
- export function resolveSessionAccount(sessionId, accounts = [], defaultActiveId = '', accountMode = 'least_used') {
191
+ /**
192
+ * Match a model ID against modelRoutes map (supports exact match or prefix without cline-pass/).
193
+ *
194
+ * @param {string} [modelId] Target model ID
195
+ * @param {Object} [modelRoutes] Model routing table
196
+ * @param {Array<Object>} [safeAccounts] Available accounts
197
+ * @returns {Object|null}
198
+ */
199
+ export function resolveModelRouteAccount(modelId, modelRoutes = {}, safeAccounts = []) {
200
+ if (!modelId || !modelRoutes || typeof modelRoutes !== 'object' || !Array.isArray(safeAccounts)) {
201
+ return null
202
+ }
203
+ const cleanId = String(modelId).trim()
204
+ const shortId = cleanId.replace(/^cline-pass\//, '')
205
+ const targetKey = modelRoutes[cleanId] || modelRoutes[shortId] || null
206
+ if (!targetKey) return null
207
+
208
+ const targetAcc = safeAccounts.find((a) => matchesAccount(a, targetKey))
209
+ if (targetAcc && !isAccountBlocked(targetAcc)) {
210
+ return targetAcc
211
+ }
212
+ return null
213
+ }
214
+
215
+ export function resolveSessionAccount(sessionId, accounts = [], defaultActiveId = '', accountMode = 'least_used', model = '', modelRoutes = {}) {
192
216
  const safeAccounts = Array.isArray(accounts) ? accounts.filter(a => a && a.present !== false) : []
193
217
  if (safeAccounts.length === 0) {
194
218
  const fallback = Array.isArray(accounts) && accounts.length > 0 ? accounts[0] : null
@@ -209,7 +233,12 @@ export function resolveSessionAccount(sessionId, accounts = [], defaultActiveId
209
233
 
210
234
  const pinnedAvailable = pinnedAccount && !isAccountBlocked(pinnedAccount) ? pinnedAccount : null
211
235
 
236
+ const routedAccount = resolveModelRouteAccount(model, modelRoutes, safeAccounts)
237
+
212
238
  const pickCandidate = () => {
239
+ if (routedAccount) {
240
+ return routedAccount
241
+ }
213
242
  if (accountMode === 'round_robin') {
214
243
  return getNextRoundRobinAccount(safeAccounts) || safeAccounts[0]
215
244
  }
@@ -222,10 +251,13 @@ export function resolveSessionAccount(sessionId, accounts = [], defaultActiveId
222
251
  return { account: pinnedAvailable, isReassigned: false, reason: 'pinned_active' }
223
252
  }
224
253
  const chosen = pickCandidate()
254
+ const noSessReason = routedAccount
255
+ ? 'model_route'
256
+ : (pinnedAccount ? 'pinned_in_cooldown_fallback' : (accountMode === 'round_robin' ? 'round_robin_ad_hoc' : 'no_session_id'))
225
257
  return {
226
258
  account: chosen,
227
259
  isReassigned: false,
228
- reason: pinnedAccount ? 'pinned_in_cooldown_fallback' : (accountMode === 'round_robin' ? 'round_robin_ad_hoc' : 'no_session_id')
260
+ reason: noSessReason
229
261
  }
230
262
  }
231
263
 
@@ -260,6 +292,21 @@ export function resolveSessionAccount(sessionId, accounts = [], defaultActiveId
260
292
  }
261
293
  }
262
294
 
295
+ // If model route is specified and available, enforce routed account:
296
+ if (routedAccount) {
297
+ const reassigned = Boolean(existing && !matchesAccount(routedAccount, existing.accountId))
298
+ sessions.set(sessionId, {
299
+ accountId: routedAccount.apiKeyEnv || routedAccount.id,
300
+ assignedAt: existing ? existing.assignedAt : now,
301
+ lastUsedAt: now
302
+ })
303
+ return {
304
+ account: routedAccount,
305
+ isReassigned: reassigned,
306
+ reason: 'model_route'
307
+ }
308
+ }
309
+
263
310
  // If in auto mode or pinned account is unavailable:
264
311
  if (existing) {
265
312
  const assigned = safeAccounts.find(a => matchesAccount(a, existing.accountId))
@@ -92,7 +92,7 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
92
92
  }
93
93
 
94
94
  // 3. Subcommand /cline switch <account>
95
- if (subcmd === 'switch') {
95
+ if (subcmd === 'switch' || subcmd === 'account' || subcmd === 'use') {
96
96
  if (!param) {
97
97
  return 'Please specify account: /cline switch <KEY_NAME>'
98
98
  }
@@ -125,8 +125,22 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
125
125
  return `Rotation skipped: ${res.message || 'no alternative configured accounts in pool'}.`
126
126
  }
127
127
 
128
- // 5. Subcommand /cline ping (fresh host reachability probe)
128
+ // 5. Subcommand /cline ping (fresh host reachability or model probe)
129
129
  if (subcmd === 'ping') {
130
+ if (param) {
131
+ const activeKey = await resolveActiveAccountKey(ctx, live())
132
+ if (!activeKey.value) {
133
+ return `Cline API Ping Failed: Missing API key for ${activeKey.envName}.`
134
+ }
135
+ const res = await smokeChat(pub.baseUrl, activeKey.value, {
136
+ model: param,
137
+ timeoutMs: Math.min(pub.smokeTimeoutMs || 25000, 15000),
138
+ })
139
+ if (res.ok) {
140
+ return `Cline Model Pong: ${param} responded in ${res.latencyMs} ms (HTTP ${res.status || 200})`
141
+ }
142
+ return `Cline Model Ping Failed for ${param}: ${res.error || `HTTP ${res.status}`}`
143
+ }
130
144
  const health = await probeHealth(pub.baseUrl, { bypassCache: true, timeoutMs: 5000 })
131
145
  if (health.ok) {
132
146
  return `Cline API Pong: ${pub.baseUrl} is reachable (latency: ${health.latencyMs} ms, HTTP ${health.status})`
@@ -141,7 +155,7 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
141
155
  summary,
142
156
  '',
143
157
  `Requests: ${sessionStats.successfulRequests} successful / ${sessionStats.totalRequests} total${sessionStats.abortedRequests ? ` (${sessionStats.abortedRequests} aborted)` : ''}${sessionStats.failedRequests ? ` (${sessionStats.failedRequests} failed)` : ''}`,
144
- `Tokens: ~${sessionStats.totalTokensEst} total (~${sessionStats.promptTokensEst} prompt, ~${sessionStats.completionTokensEst} completion)`,
158
+ `Tokens: ~${sessionStats.totalTokensEst} total (~${sessionStats.promptTokensEst} prompt, ~${sessionStats.completionTokensEst} completion${sessionStats.reasoningTokensEst ? `, ~${sessionStats.reasoningTokensEst} reasoning` : ''})`,
145
159
  `Last Latency: ${sessionStats.lastLatencyMs !== null ? `${sessionStats.lastLatencyMs} ms` : '—'}`,
146
160
  `Last Request: ${sessionStats.lastRequestAt ? new Date(sessionStats.lastRequestAt).toLocaleTimeString() : '—'}`,
147
161
  ]
@@ -151,7 +165,8 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
151
165
  if (sessionStats.byModel && Object.keys(sessionStats.byModel).length) {
152
166
  lines.push('', 'By Model:')
153
167
  for (const [mId, mStats] of Object.entries(sessionStats.byModel)) {
154
- lines.push(` ${mId}: ${mStats.requests} reqs, ~${mStats.promptTokens + mStats.completionTokens} tokens`)
168
+ const rStr = mStats.reasoningTokens ? ` (${mStats.reasoningTokens} reasoning)` : ''
169
+ lines.push(` ${mId}: ${mStats.requests} reqs, ~${mStats.promptTokens + mStats.completionTokens} tokens${rStr}`)
155
170
  }
156
171
  }
157
172
  if (sessionStats.lastSmoke) {
@@ -28,6 +28,7 @@ export function createEmptyStats() {
28
28
  rateLimited429: 0,
29
29
  promptTokens: 0,
30
30
  completionTokens: 0,
31
+ reasoningTokens: 0,
31
32
  totalTokens: 0
32
33
  },
33
34
  byDay: {},
@@ -230,6 +231,7 @@ export function recordUsage({
230
231
  accountId = 'default',
231
232
  promptTokens = 0,
232
233
  completionTokens = 0,
234
+ reasoningTokens = 0,
233
235
  totalTokens = 0,
234
236
  isError = false,
235
237
  is429 = false,
@@ -244,6 +246,7 @@ export function recordUsage({
244
246
 
245
247
  const safePrompt = Math.max(0, Number(promptTokens) || 0)
246
248
  const safeComp = Math.max(0, Number(completionTokens) || 0)
249
+ const safeReasoning = Math.max(0, Number(reasoningTokens) || 0)
247
250
  const safeTotal = Math.max(safePrompt + safeComp, Number(totalTokens) || 0)
248
251
 
249
252
  // Totals
@@ -258,6 +261,7 @@ export function recordUsage({
258
261
  }
259
262
  inMemoryStats.totals.promptTokens += safePrompt
260
263
  inMemoryStats.totals.completionTokens += safeComp
264
+ inMemoryStats.totals.reasoningTokens = (inMemoryStats.totals.reasoningTokens || 0) + safeReasoning
261
265
  inMemoryStats.totals.totalTokens += safeTotal
262
266
 
263
267
  // Helper to increment bucket
@@ -267,12 +271,14 @@ export function recordUsage({
267
271
  requests: 0,
268
272
  promptTokens: 0,
269
273
  completionTokens: 0,
274
+ reasoningTokens: 0,
270
275
  totalTokens: 0
271
276
  }
272
277
  }
273
278
  container[key].requests += 1
274
279
  container[key].promptTokens += safePrompt
275
280
  container[key].completionTokens += safeComp
281
+ container[key].reasoningTokens = (container[key].reasoningTokens || 0) + safeReasoning
276
282
  container[key].totalTokens += safeTotal
277
283
  }
278
284
 
@@ -303,3 +309,25 @@ export function resetStats(customPath) {
303
309
  saveStatsSync(customPath)
304
310
  return inMemoryStats
305
311
  }
312
+
313
+
314
+ /**
315
+ * Export usage analytics as a formatted CSV string.
316
+ *
317
+ * @returns {string} CSV format
318
+ */
319
+ export function exportStatsAsCsv() {
320
+ const lines = ['Dimension,Key,Requests,PromptTokens,CompletionTokens,ReasoningTokens,TotalTokens']
321
+ const addRows = (dimension, obj) => {
322
+ for (const [key, val] of Object.entries(obj || {})) {
323
+ lines.push(`${dimension},"${key}",${val.requests || 0},${val.promptTokens || 0},${val.completionTokens || 0},${val.reasoningTokens || 0},${val.totalTokens || 0}`)
324
+ }
325
+ }
326
+ const totals = inMemoryStats.totals || {}
327
+ lines.push(`Total,"all",${totals.requests || 0},${totals.promptTokens || 0},${totals.completionTokens || 0},${totals.reasoningTokens || 0},${totals.totalTokens || 0}`)
328
+ addRows('Day', inMemoryStats.byDay)
329
+ addRows('Month', inMemoryStats.byMonth)
330
+ addRows('Model', inMemoryStats.byModel)
331
+ addRows('Account', inMemoryStats.byAccount)
332
+ return lines.join('\n')
333
+ }
package/lib/telemetry.js CHANGED
@@ -9,6 +9,7 @@ export const sessionStats = {
9
9
  abortedRequests: 0,
10
10
  promptTokensEst: 0,
11
11
  completionTokensEst: 0,
12
+ reasoningTokensEst: 0,
12
13
  totalTokensEst: 0,
13
14
  lastLatencyMs: null,
14
15
  lastRequestAt: null,
@@ -24,6 +25,7 @@ export function recordSessionRequest({
24
25
  aborted = false,
25
26
  promptTokens = 0,
26
27
  completionTokens = 0,
28
+ reasoningTokens = 0,
27
29
  model,
28
30
  }) {
29
31
  sessionStats.totalRequests += 1
@@ -40,14 +42,16 @@ export function recordSessionRequest({
40
42
  sessionStats.lastRequestAt = Date.now()
41
43
  sessionStats.promptTokensEst += Number(promptTokens) || 0
42
44
  sessionStats.completionTokensEst += Number(completionTokens) || 0
45
+ sessionStats.reasoningTokensEst = (sessionStats.reasoningTokensEst || 0) + (Number(reasoningTokens) || 0)
43
46
  sessionStats.totalTokensEst += (Number(promptTokens) || 0) + (Number(completionTokens) || 0)
44
47
 
45
48
  if (model) {
46
49
  if (!sessionStats.byModel) sessionStats.byModel = {}
47
- const m = sessionStats.byModel[model] || { requests: 0, promptTokens: 0, completionTokens: 0 }
50
+ const m = sessionStats.byModel[model] || { requests: 0, promptTokens: 0, completionTokens: 0, reasoningTokens: 0 }
48
51
  m.requests += 1
49
52
  m.promptTokens += Number(promptTokens) || 0
50
53
  m.completionTokens += Number(completionTokens) || 0
54
+ m.reasoningTokens = (m.reasoningTokens || 0) + (Number(reasoningTokens) || 0)
51
55
  sessionStats.byModel[model] = m
52
56
  }
53
57
  }
@@ -69,6 +73,7 @@ export function resetSessionStats() {
69
73
  sessionStats.abortedRequests = 0
70
74
  sessionStats.promptTokensEst = 0
71
75
  sessionStats.completionTokensEst = 0
76
+ sessionStats.reasoningTokensEst = 0
72
77
  sessionStats.totalTokensEst = 0
73
78
  sessionStats.lastLatencyMs = null
74
79
  sessionStats.lastRequestAt = null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.5.21",
3
+ "version": "0.5.22",
4
4
  "description": "DeepSeek Harness companion for ClineBot / ClinePass: dynamic subscription models sync, quota exhaustion warnings, session metrics, dedicated settings page, live usage limits, and /cline slash-command.",
5
5
  "license": "MIT",
6
6
  "type": "module",