@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/lib/config.js CHANGED
@@ -12,7 +12,6 @@ import {
12
12
  getActiveModelIds,
13
13
  } from './models.js'
14
14
 
15
-
16
15
  // DSH settings.describe only publishes fields marked volatile. Without them the
17
16
  // namespace is omitted and the configuration page stays unavailable.
18
17
  if (typeof z.prototype?.volatile !== 'function') {
@@ -58,6 +57,21 @@ export const Config = z.object({
58
57
  maxTokens: z.number().default(8192),
59
58
  })).default([])
60
59
  .description('Custom user-defined model context length and max token overrides.').volatile(),
60
+ modelReasoningDefaults: z.dict(z.string()).default({})
61
+ .description('Configured default reasoning effort per model (low, medium, high, max).').volatile(),
62
+ customModels: z.array(z.object({
63
+ id: z.string(),
64
+ name: z.string(),
65
+ contextLength: z.number().default(128000),
66
+ maxTokens: z.number().default(8192),
67
+ category: z.string().default('general'),
68
+ isReasoning: z.boolean().default(false),
69
+ })).default([])
70
+ .description('User-defined custom gateway models.').volatile(),
71
+ proxyMode: z.boolean().default(true)
72
+ .description('When true, registers local transparent loopback proxy for failover and token tracking.').volatile(),
73
+ statsPath: z.string().default('~/.dsh/clinebot-stats.json')
74
+ .description('Local on-disk persistent path for token usage analytics.').volatile(),
61
75
  timeoutMs: z.number().default(DEFAULT_TIMEOUT_MS)
62
76
  .description('HTTP probe timeout in milliseconds.').volatile(),
63
77
  smokeTimeoutMs: z.number().default(DEFAULT_SMOKE_TIMEOUT_MS)
@@ -73,7 +87,6 @@ export const Config = z.object({
73
87
  .description('Manually pinned active account envName or empty for auto/default.').volatile(),
74
88
  })
75
89
 
76
-
77
90
  function isVolatileRef(value) {
78
91
  return !!value && typeof value === 'object' && !Array.isArray(value) && typeof value.get === 'function'
79
92
  }
@@ -111,9 +124,10 @@ export function volatileConfig(cfg) {
111
124
  export function publicConfig(cfg) {
112
125
  cfg = plainConfig(cfg)
113
126
  const dynamic = Array.isArray(cfg?.dynamicModels) ? cfg.dynamicModels : []
127
+ const custom = Array.isArray(cfg?.customModels) ? cfg.customModels : []
114
128
  const modelContextOverrides = Array.isArray(cfg?.modelContextOverrides) ? cfg.modelContextOverrides : []
115
129
  const isSynced = dynamic.length > 0
116
- const allModels = getAllModels(dynamic, modelContextOverrides)
130
+ const allModels = getAllModels(dynamic, modelContextOverrides, custom)
117
131
  const allDefaultIds = allModels.map((m) => m.id)
118
132
 
119
133
  let disabledList = Array.isArray(cfg?.disabledModels) ? cfg.disabledModels : []
@@ -148,6 +162,10 @@ export function publicConfig(cfg) {
148
162
  planSynced: isSynced,
149
163
  planSyncedAt,
150
164
  modelContextOverrides,
165
+ modelReasoningDefaults: cfg?.modelReasoningDefaults && typeof cfg.modelReasoningDefaults === 'object' ? cfg.modelReasoningDefaults : {},
166
+ customModels: custom,
167
+ proxyMode: cfg?.proxyMode !== false,
168
+ statsPath: String(cfg?.statsPath || '~/.dsh/clinebot-stats.json'),
151
169
  disabledModels: disabledList,
152
170
  enabledModels: activeIds,
153
171
  timeoutMs: Number(cfg?.timeoutMs) || DEFAULT_TIMEOUT_MS,
package/lib/index.js CHANGED
@@ -18,7 +18,9 @@ import { registerSettingsRoutes } from './routes/settings.js'
18
18
  import { registerAccountsRoutes } from './routes/accounts.js'
19
19
  import { registerModelsRoutes } from './routes/models.js'
20
20
  import { registerAuthRoutes } from './routes/auth.js'
21
+ import { registerProxyRoutes } from './routes/proxy.js'
21
22
  import { registerSlashCommand } from './slash-command.js'
23
+ import { recordUsage } from './stats-storage.js'
22
24
 
23
25
  export const name = '@goodandready/dsh-clinebot'
24
26
  export const inject = ['webServer', 'credentials']
@@ -135,6 +137,7 @@ export function apply(ctx, config) {
135
137
  registerAccountsRoutes(ctx, routeEnv)
136
138
  registerModelsRoutes(ctx, routeEnv)
137
139
  registerAuthRoutes(ctx, routeEnv)
140
+ registerProxyRoutes(ctx, routeEnv)
138
141
  }
139
142
 
140
143
  registerSlashCommand(ctx, {
@@ -198,6 +201,15 @@ export function apply(ctx, config) {
198
201
  completionTokens,
199
202
  model: options?.model,
200
203
  })
204
+ recordUsage({
205
+ model: options?.model,
206
+ promptTokens,
207
+ completionTokens,
208
+ totalTokens: promptTokens + completionTokens,
209
+ isError: true,
210
+ is429,
211
+ statsPath: live().statsPath,
212
+ })
201
213
  if (is429 || isExceeded) {
202
214
  handleStream429()
203
215
  }
@@ -218,6 +230,13 @@ export function apply(ctx, config) {
218
230
  completionTokens,
219
231
  model: options?.model,
220
232
  })
233
+ recordUsage({
234
+ model: options?.model,
235
+ promptTokens,
236
+ completionTokens,
237
+ totalTokens: promptTokens + completionTokens,
238
+ statsPath: live().statsPath,
239
+ })
221
240
  }
222
241
  }
223
242
  yield chunk
@@ -230,6 +249,13 @@ export function apply(ctx, config) {
230
249
  completionTokens,
231
250
  model: options?.model,
232
251
  })
252
+ recordUsage({
253
+ model: options?.model,
254
+ promptTokens,
255
+ completionTokens,
256
+ totalTokens: promptTokens + completionTokens,
257
+ statsPath: live().statsPath,
258
+ })
233
259
  }
234
260
  } catch (err) {
235
261
  if (isCline) {
@@ -244,6 +270,15 @@ export function apply(ctx, config) {
244
270
  completionTokens,
245
271
  model: options?.model,
246
272
  })
273
+ recordUsage({
274
+ model: options?.model,
275
+ promptTokens,
276
+ completionTokens,
277
+ totalTokens: promptTokens + completionTokens,
278
+ isError: true,
279
+ is429: status === 429,
280
+ statsPath: live().statsPath,
281
+ })
247
282
  if (status === 429 || isQuotaExceededError(msg)) {
248
283
  handleStream429()
249
284
  }
package/lib/models.js CHANGED
@@ -336,7 +336,7 @@ export function parsePlanIncludedModels(includedInput) {
336
336
  *
337
337
  * Applies user-defined modelContextOverrides (custom contextLength and maxTokens).
338
338
  */
339
- export function getAllModels(dynamicModels = [], modelContextOverrides = []) {
339
+ export function getAllModels(dynamicModels = [], modelContextOverrides = [], customModels = []) {
340
340
  const normalizedDynamic = (Array.isArray(dynamicModels) ? dynamicModels : []).map(migrateModelEntry)
341
341
  const overridesMap = new Map()
342
342
  if (Array.isArray(modelContextOverrides)) {
@@ -374,9 +374,10 @@ export function getAllModels(dynamicModels = [], modelContextOverrides = []) {
374
374
  }
375
375
  }
376
376
 
377
+ let baseModels = []
377
378
  if (normalizedDynamic.length > 0) {
378
379
  const normalize = (s) => String(s || '').toLowerCase().replace(/[\s._-]+/g, '')
379
- return normalizedDynamic.map((dm) => {
380
+ baseModels = normalizedDynamic.map((dm) => {
380
381
  const targetNorm = normalize(dm.id.replace(/^cline-pass\//, ''))
381
382
  const curated = CLINE_MODELS.find(
382
383
  (cm) => cm.id === dm.id || normalize(cm.id.replace(/^cline-pass\//, '')) === targetNorm || normalize(cm.name) === targetNorm
@@ -407,23 +408,47 @@ export function getAllModels(dynamicModels = [], modelContextOverrides = []) {
407
408
  unverified: false,
408
409
  })
409
410
  })
411
+ } else {
412
+ baseModels = CLINE_MODELS.map((m) => applyOverride({ ...m, unverified: true }))
413
+ }
414
+
415
+ if (Array.isArray(customModels) && customModels.length > 0) {
416
+ const existingIds = new Set(baseModels.map((m) => m.id))
417
+ for (const cm of customModels) {
418
+ if (cm && cm.id && !existingIds.has(cm.id)) {
419
+ const fullCustom = {
420
+ id: cm.id,
421
+ name: cm.name || cm.id,
422
+ description: cm.description || `Custom model ${cm.name || cm.id}`,
423
+ contextLength: cm.contextLength || 128000,
424
+ maxTokens: cm.maxTokens || 8192,
425
+ input: ['text'],
426
+ category: cm.category || 'general',
427
+ isCustom: true,
428
+ reasoningEfforts: cm.isReasoning ? ['low', 'medium', 'high', 'max'] : undefined,
429
+ unverified: false,
430
+ }
431
+ baseModels.push(applyOverride(fullCustom))
432
+ existingIds.add(cm.id)
433
+ }
434
+ }
410
435
  }
411
436
 
412
- return CLINE_MODELS.map((m) => applyOverride({ ...m, unverified: true }))
437
+ return baseModels
413
438
  }
414
439
 
415
- export function findModel(id, dynamicModels = [], modelContextOverrides = []) {
440
+ export function findModel(id, dynamicModels = [], modelContextOverrides = [], customModels = []) {
416
441
  const all = getAllModels(dynamicModels, modelContextOverrides)
417
442
  const found = all.find((m) => m.id === id)
418
443
  if (found) return found
419
444
  return CLINE_MODELS.find((m) => m.id === id) || null
420
445
  }
421
446
 
422
- export function isSupportedModel(id, dynamicModels = [], modelContextOverrides = []) {
447
+ export function isSupportedModel(id, dynamicModels = [], modelContextOverrides = [], customModels = []) {
423
448
  return findModel(id, dynamicModels, modelContextOverrides) !== null
424
449
  }
425
450
 
426
- export function getDefaultModelIds(dynamicModels = [], modelContextOverrides = []) {
451
+ export function getDefaultModelIds(dynamicModels = [], modelContextOverrides = [], customModels = []) {
427
452
  return getAllModels(dynamicModels, modelContextOverrides).map((m) => m.id)
428
453
  }
429
454
 
@@ -1,30 +1,29 @@
1
1
  import os from 'node:os'
2
2
  import path from 'node:path'
3
- import { publicConfig, plainConfig, Config, LLM_PI_AI_NS } from './config.js'
4
- import { publicUsage } from './http.js'
5
3
  import {
6
4
  PROVIDER_ID,
7
5
  PROVIDER_DISPLAY_NAME,
8
6
  getAllModels,
9
- loadModelsDiskCache,
10
7
  saveModelsDiskCache,
8
+ loadModelsDiskCache,
9
+ OBSOLETE_MODEL_ID_MAP,
10
+ migrateModelEntry,
11
11
  } from './models.js'
12
12
  import {
13
- resolveKeyValue,
14
- resolveAccountPool,
15
13
  probeHealth,
16
14
  fetchUsageLimits,
17
- buildPiAiProvider,
18
15
  sessionStats,
19
- getLastRotation,
20
16
  usageCache,
17
+ buildPiAiProvider,
21
18
  DEFAULT_API_KEY_ENV,
22
19
  } from './cline-client.js'
20
+ import { publicConfig, plainConfig, Config, LLM_PI_AI_NS } from './config.js'
21
+ import { publicUsage } from './http.js'
22
+ import { resolveKeyValue } from './credential-refs.js'
23
+ import { resolveAccountPool, getLastRotation } from './account-pool.js'
23
24
 
24
25
  export function resolvePathWithHome(p) {
25
- if (!p || typeof p !== 'string') return ''
26
- if (p === '~') return os.homedir()
27
- if (p.startsWith('~/') || p.startsWith('~\\')) {
26
+ if (typeof p === 'string' && p.startsWith('~/')) {
28
27
  return path.join(os.homedir(), p.slice(2))
29
28
  }
30
29
  return p
@@ -88,7 +87,7 @@ export async function buildStatus(ctx, cfg) {
88
87
  ])
89
88
 
90
89
  const activeAcc = await resolveActiveAccountKey(ctx, cfg, pool)
91
- const allModels = getAllModels(pub.dynamicModels, pub.modelContextOverrides)
90
+ const allModels = getAllModels(pub.dynamicModels, pub.modelContextOverrides, pub.customModels)
92
91
 
93
92
  let usage = null
94
93
  const keyToUse = activeAcc.value || key.value
@@ -97,19 +96,40 @@ export async function buildStatus(ctx, cfg) {
97
96
  }
98
97
 
99
98
  let quotaWarning = null
100
- if (usage?.windows?.fiveHour) {
101
- const pct = usage.windows.fiveHour.percentUsed
102
- if (pct >= 95) {
99
+ if (usage?.windows) {
100
+ const fiveHour = usage.windows.fiveHour
101
+ const weekly = usage.windows.weekly
102
+ const monthly = usage.windows.monthly
103
+
104
+ if (fiveHour && typeof fiveHour.percentUsed === 'number') {
105
+ if (fiveHour.percentUsed >= 95) {
106
+ quotaWarning = {
107
+ level: 'exhausted',
108
+ message: `5-hour rolling limit is almost exhausted (${fiveHour.percentUsed}%). New requests may be rejected until quota reset.`,
109
+ resetsAt: fiveHour.resetsAt,
110
+ }
111
+ } else if (fiveHour.percentUsed >= 90) {
112
+ quotaWarning = {
113
+ level: 'warning',
114
+ message: `Low quota warning: ${fiveHour.percentUsed}% of 5-hour limit consumed (<10% remaining).`,
115
+ resetsAt: fiveHour.resetsAt,
116
+ }
117
+ }
118
+ }
119
+
120
+ if (!quotaWarning && weekly && typeof weekly.percentUsed === 'number' && weekly.percentUsed >= 90) {
103
121
  quotaWarning = {
104
- level: 'exhausted',
105
- message: `5-hour rolling limit is almost exhausted (${pct}%). New requests may be rejected until quota reset.`,
106
- resetsAt: usage.windows.fiveHour.resetsAt,
122
+ level: weekly.percentUsed >= 99 ? 'exhausted' : 'warning',
123
+ message: `Low weekly quota warning: ${weekly.percentUsed}% consumed (${weekly.remainingPercent ?? (100 - weekly.percentUsed)}% remaining).`,
124
+ resetsAt: weekly.resetsAt,
107
125
  }
108
- } else if (pct >= 80) {
126
+ }
127
+
128
+ if (!quotaWarning && monthly && typeof monthly.percentUsed === 'number' && monthly.percentUsed >= 90) {
109
129
  quotaWarning = {
110
- level: 'warning',
111
- message: `Notice: ${pct}% of the 5-hour rolling limit has been consumed.`,
112
- resetsAt: usage.windows.fiveHour.resetsAt,
130
+ level: monthly.percentUsed >= 99 ? 'exhausted' : 'warning',
131
+ message: `Low monthly quota warning: ${monthly.percentUsed}% consumed (${monthly.remainingPercent ?? (100 - monthly.percentUsed)}% remaining).`,
132
+ resetsAt: monthly.resetsAt,
113
133
  }
114
134
  }
115
135
  }
@@ -127,7 +147,7 @@ export async function buildStatus(ctx, cfg) {
127
147
  accounts: pool.map((acc) => {
128
148
  const cacheKey = `cline:usage:${(acc.value || '').slice(-8)}`
129
149
  const cached = usageCache.get(cacheKey)?.data
130
- const pct = cached?.windows?.fiveHour?.percentUsed
150
+ const pct = cached?.windows?.weekly?.percentUsed ?? cached?.windows?.monthly?.percentUsed ?? cached?.windows?.fiveHour?.percentUsed ?? null
131
151
  return {
132
152
  id: acc.id,
133
153
  label: acc.label,
@@ -156,17 +176,22 @@ export async function upsertPiAiProvider(ctx, cfg, activeModelIds) {
156
176
  }
157
177
 
158
178
  const pub = publicConfig(cfg)
159
- const allModels = getAllModels(pub.dynamicModels, pub.modelContextOverrides)
179
+ const allModels = getAllModels(pub.dynamicModels, pub.modelContextOverrides, pub.customModels)
160
180
  const allowedSet = new Set(activeModelIds || pub.enabledModels)
161
181
  const modelsToRegister = allModels.filter((m) => allowedSet.has(m.id))
162
182
 
163
183
  const activeAcc = await resolveActiveAccountKey(ctx, cfg)
164
184
 
185
+ const port = ctx?.webServer?.port || process.env.PORT || 3080
186
+ const effectiveBaseUrl = pub.proxyMode !== false
187
+ ? `http://127.0.0.1:${port}/dsh-clinebot/v1`
188
+ : pub.baseUrl
189
+
165
190
  const providerObj = buildPiAiProvider({
166
- baseUrl: pub.baseUrl,
191
+ baseUrl: effectiveBaseUrl,
167
192
  apiKeyEnv: activeAcc?.apiKeyEnv || pub.apiKeyEnv,
168
193
  models: modelsToRegister.length ? modelsToRegister : allModels,
169
- customModels: pub.dynamicModels,
194
+ customModels: [...(pub.dynamicModels || []), ...(pub.customModels || [])],
170
195
  displayName: PROVIDER_DISPLAY_NAME,
171
196
  })
172
197
 
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Transparent Loopback Proxy & Zero-Downtime Failover (Issue #127)
3
+ *
4
+ * Exposes local OpenAI-compatible endpoints:
5
+ * - POST /dsh-clinebot/v1/chat/completions
6
+ * - GET /dsh-clinebot/v1/models
7
+ *
8
+ * Implements:
9
+ * 1. Sticky session routing with least-used quota allocation.
10
+ * 2. Transparent upstream retry on 429 / quota exhaustion before first chunk is streamed.
11
+ * 3. Accurate streaming SSE token usage interception & persistent telemetry recording.
12
+ * 4. Model reasoning_effort default injection from config.
13
+ */
14
+
15
+ import { writeJson, readBody } from '../http.js'
16
+ import { publicConfig, plainConfig } from '../config.js'
17
+ import { resolveAccountPool } from '../account-pool.js'
18
+ import { resolveSessionAccount, markAccountCooldown } from '../session-router.js'
19
+ import { recordUsage } from '../stats-storage.js'
20
+ import { getAllModels } from '../models.js'
21
+
22
+ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderState }) {
23
+ // 1. POST /dsh-clinebot/v1/chat/completions
24
+ ctx.effect(() => ctx.webServer.register({
25
+ kind: 'exact',
26
+ path: '/dsh-clinebot/v1/chat/completions',
27
+ handler: async (req, res) => {
28
+ if (req.method !== 'POST') {
29
+ return writeJson(res, 405, { ok: false, error: 'POST only' })
30
+ }
31
+
32
+ let bodyBuf
33
+ try {
34
+ bodyBuf = await readBody(req)
35
+ } catch (err) {
36
+ return writeJson(res, 400, { ok: false, error: String(err?.message || err) })
37
+ }
38
+
39
+ let body
40
+ try {
41
+ body = JSON.parse(bodyBuf.toString('utf8') || '{}')
42
+ } catch {
43
+ return writeJson(res, 400, { ok: false, error: 'Invalid JSON request body' })
44
+ }
45
+
46
+ const cfg = plainConfig(live())
47
+ const pub = publicConfig(cfg)
48
+
49
+ // Session identification
50
+ const sessionId = String(
51
+ req.headers['x-dsh-session-id'] ||
52
+ req.headers['x-session-id'] ||
53
+ body?.conversation_id ||
54
+ body?.chat_id ||
55
+ body?.user ||
56
+ ''
57
+ ).trim()
58
+
59
+ // Inject model reasoning effort if model has default configured and payload omitted it
60
+ if (
61
+ pub.modelReasoningDefaults &&
62
+ body.model &&
63
+ pub.modelReasoningDefaults[body.model] &&
64
+ !body.reasoning_effort
65
+ ) {
66
+ body.reasoning_effort = pub.modelReasoningDefaults[body.model]
67
+ }
68
+
69
+ const pool = await resolveAccountPool(ctx, cfg)
70
+ const isStreaming = Boolean(body.stream) || String(req.headers.accept || '').includes('text/event-stream')
71
+
72
+ // Transparent retry loop (up to max configured accounts, min 1, max 3)
73
+ const maxRetries = Math.min(Math.max(1, pool.length), 3)
74
+ let attempt = 0
75
+ let lastError = null
76
+ let targetAccount = null
77
+
78
+ while (attempt < maxRetries) {
79
+ attempt++
80
+ const routeResult = resolveSessionAccount(sessionId, pool, pub.activeAccount)
81
+ targetAccount = routeResult.account
82
+
83
+ if (!targetAccount || !targetAccount.value) {
84
+ return writeJson(res, 503, {
85
+ error: {
86
+ message: 'No available ClinePass API keys configured in pool',
87
+ type: 'service_unavailable',
88
+ code: 'no_keys_available'
89
+ }
90
+ })
91
+ }
92
+
93
+ const upstreamUrl = `${pub.baseUrl}/chat/completions`
94
+ const requestHeaders = {
95
+ 'Content-Type': 'application/json',
96
+ 'Authorization': `Bearer ${targetAccount.value}`,
97
+ 'Accept': isStreaming ? 'text/event-stream' : 'application/json',
98
+ 'User-Agent': '@goodandready/dsh-clinebot/0.4.8'
99
+ }
100
+
101
+ let upstreamRes
102
+ try {
103
+ upstreamRes = await fetch(upstreamUrl, {
104
+ method: 'POST',
105
+ headers: requestHeaders,
106
+ body: JSON.stringify(body),
107
+ keepalive: true
108
+ })
109
+ } catch (fetchErr) {
110
+ lastError = fetchErr
111
+ // Network errors: retry with next account if available
112
+ continue
113
+ }
114
+
115
+ // If upstream rejected with 429 or 403 quota exhaustion before streaming, failover!
116
+ if (upstreamRes.status === 429 || upstreamRes.status === 402) {
117
+ const accountId = targetAccount.id || targetAccount.label
118
+ markAccountCooldown(accountId, 60000, null, 'upstream_429')
119
+ recordUsage({
120
+ model: body.model,
121
+ accountId,
122
+ is429: true,
123
+ isError: true,
124
+ statsPath: pub.statsPath
125
+ })
126
+ ctx?.logger?.warn?.(`[dsh-clinebot:proxy] Account "${accountId}" hit 429, attempting failover (attempt ${attempt}/${maxRetries})`)
127
+ continue
128
+ }
129
+
130
+ // Upstream responded with non-retryable response: stream or write JSON to client
131
+ if (isStreaming && upstreamRes.status === 200 && upstreamRes.body) {
132
+ res.statusCode = 200
133
+ res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
134
+ res.setHeader('Cache-Control', 'no-cache, no-transform')
135
+ res.setHeader('Connection', 'keep-alive')
136
+ if (typeof res.flushHeaders === 'function') {
137
+ res.flushHeaders()
138
+ }
139
+
140
+ let promptTokens = 0
141
+ let completionTokens = 0
142
+ let totalTokens = 0
143
+ let buffer = ''
144
+
145
+ const reader = upstreamRes.body.getReader()
146
+ const decoder = new TextDecoder('utf8')
147
+
148
+ try {
149
+ while (true) {
150
+ const { done, value } = await reader.read()
151
+ if (done) break
152
+
153
+ const chunkStr = decoder.decode(value, { stream: true })
154
+ res.write(value)
155
+
156
+ // Parse SSE lines for usage metrics
157
+ buffer += chunkStr
158
+ const lines = buffer.split('\n')
159
+ buffer = lines.pop() // keep incomplete last line
160
+
161
+ for (const line of lines) {
162
+ const trimmed = line.trim()
163
+ if (trimmed.startsWith('data: ') && trimmed !== 'data: [DONE]') {
164
+ try {
165
+ const data = JSON.parse(trimmed.slice(6))
166
+ if (data?.usage) {
167
+ promptTokens = Number(data.usage.prompt_tokens) || promptTokens
168
+ completionTokens = Number(data.usage.completion_tokens) || completionTokens
169
+ totalTokens = Number(data.usage.total_tokens) || totalTokens
170
+ }
171
+ } catch {
172
+ /* ignore partial or non-json SSE lines */
173
+ }
174
+ }
175
+ }
176
+ }
177
+ } catch (streamErr) {
178
+ ctx?.logger?.warn?.(`[dsh-clinebot:proxy] Error streaming SSE chunk: ${streamErr?.message || streamErr}`)
179
+ } finally {
180
+ res.end()
181
+ recordUsage({
182
+ model: body.model,
183
+ accountId: targetAccount.id || targetAccount.label,
184
+ promptTokens,
185
+ completionTokens,
186
+ totalTokens,
187
+ statsPath: pub.statsPath
188
+ })
189
+ }
190
+ return
191
+ }
192
+
193
+ // Non-streaming JSON response or error status from upstream
194
+ const resText = await upstreamRes.text()
195
+ let resJson = null
196
+ try {
197
+ resJson = JSON.parse(resText)
198
+ } catch {
199
+ resJson = null
200
+ }
201
+
202
+ if (resJson?.usage) {
203
+ recordUsage({
204
+ model: body.model,
205
+ accountId: targetAccount.id || targetAccount.label,
206
+ promptTokens: resJson.usage.prompt_tokens,
207
+ completionTokens: resJson.usage.completion_tokens,
208
+ totalTokens: resJson.usage.total_tokens,
209
+ isError: upstreamRes.status >= 400,
210
+ statsPath: pub.statsPath
211
+ })
212
+ }
213
+
214
+ res.statusCode = upstreamRes.status
215
+ res.setHeader('Content-Type', 'application/json; charset=utf-8')
216
+ res.end(resText)
217
+ return
218
+ }
219
+
220
+ // Exhausted all retries
221
+ writeJson(res, 429, {
222
+ error: {
223
+ message: 'All configured accounts in pool exceeded rate limits or quota',
224
+ type: 'rate_limit_exceeded',
225
+ code: 'rate_limit_exceeded',
226
+ details: lastError?.message || null
227
+ }
228
+ })
229
+ },
230
+ }), 'dsh-clinebot: /v1/chat/completions')
231
+
232
+ // 2. GET /dsh-clinebot/v1/models (OpenAI standard)
233
+ ctx.effect(() => ctx.webServer.register({
234
+ kind: 'exact',
235
+ path: '/dsh-clinebot/v1/models',
236
+ handler: async (req, res) => {
237
+ if (req.method !== 'GET') {
238
+ return writeJson(res, 405, { ok: false, error: 'GET only' })
239
+ }
240
+ const pub = publicConfig(live())
241
+ const dynamic = Array.isArray(pub.dynamicModels) ? pub.dynamicModels : []
242
+ const custom = Array.isArray(pub.customModels) ? pub.customModels : []
243
+ const overrides = Array.isArray(pub.modelContextOverrides) ? pub.modelContextOverrides : []
244
+ const allModels = getAllModels(dynamic, overrides, custom)
245
+
246
+ const modelsData = allModels.map(m => ({
247
+ id: m.id,
248
+ object: 'model',
249
+ created: 1700000000,
250
+ owned_by: 'clinebot',
251
+ permission: [],
252
+ root: m.id,
253
+ parent: null
254
+ }))
255
+
256
+ writeJson(res, 200, {
257
+ object: 'list',
258
+ data: modelsData
259
+ })
260
+ },
261
+ }), 'dsh-clinebot: /v1/models')
262
+ }
@@ -3,6 +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
7
 
7
8
  export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProviderState, triggerAutoDiscover }) {
8
9
  // 1. GET /dsh-clinebot/status
@@ -171,4 +172,37 @@ export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProvider
171
172
  }
172
173
  },
173
174
  }), 'dsh-clinebot: /save-key')
175
+
176
+ // 4. GET /dsh-clinebot/stats — persistent token analytics summary (Issue #129)
177
+ ctx.effect(() => ctx.webServer.register({
178
+ kind: 'exact',
179
+ path: '/dsh-clinebot/stats',
180
+ handler: async (req, res) => {
181
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
182
+ if (!assertTrustedSettingsRequest(req, res)) return
183
+ try {
184
+ const stats = getStatsSummary()
185
+ writeJson(res, 200, { ok: true, stats })
186
+ } catch (err) {
187
+ writeJson(res, 500, { ok: false, error: String(err?.message || err) })
188
+ }
189
+ },
190
+ }), 'dsh-clinebot: /stats')
191
+
192
+ // 5. POST /dsh-clinebot/stats/reset — reset persistent stats (Issue #129)
193
+ ctx.effect(() => ctx.webServer.register({
194
+ kind: 'exact',
195
+ path: '/dsh-clinebot/stats/reset',
196
+ handler: async (req, res) => {
197
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
198
+ if (!assertTrustedSettingsRequest(req, res)) return
199
+ try {
200
+ const pub = publicConfig(live())
201
+ const stats = resetStats(pub.statsPath)
202
+ writeJson(res, 200, { ok: true, stats })
203
+ } catch (err) {
204
+ writeJson(res, 500, { ok: false, error: String(err?.message || err) })
205
+ }
206
+ },
207
+ }), 'dsh-clinebot: /stats/reset')
174
208
  }