@goodandready/dsh-clinebot 0.5.6 → 0.5.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,28 @@ 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.8] - 2026-09-30
9
+
10
+ ### Fixed
11
+ - **Settings PUT Method & Form Error Recovery (Issue #156)**: Fixed `handleConfigPatch` in settings UI client to use `PUT /config` conforming to API routes and preserve unsaved form drafts on server errors.
12
+ - **Client Telemetry Wire Envelope Normalization (Issue #159)**: Fixed `statsSummary` in status payload to align with persistent storage format and accurately display model token consumption and totals.
13
+ - **Deduplication of Proxy & LLM Stream Telemetry (Issue #160)**: Avoided double-counting tokens and latency when requests pass through loopback proxy into `llm/stream`.
14
+ - **Proxy Error Mapping & 429 Header Preservation (Issue #166)**: Differentiated network unreachable/ECONNREFUSED errors as HTTP 502 Bad Gateway instead of false 429, and preserved upstream `Retry-After` headers.
15
+ - **Fetch Response Body Disposal Before Retry (Issue #167)**: Explicitly cancelled upstream HTTP response bodies before triggering retry or account failover on rate-limited or error attempts.
16
+ - **Dynamic Models Catalog Synchronization (Issue #168)**: Pruned deleted upstream models and reconciled metadata diffs on subscription plan updates using `isCatalogDifferent`.
17
+ - **Collision-Free Opaque Usage Cache Key (Issue #169)**: Replaced 8-character key suffix with SHA-256 hash of `baseUrl` and full API key to prevent cross-account cache collisions and protect key secrecy.
18
+ - **Custom Models Resolution Across Routes & Slash Commands (Issue #170)**: Forwarded user-defined custom models through catalog lookup, validation, `/cline test`, `/cline models`, and context configuration routes.
19
+ - **Local Proxy Bearer Token Redaction in Provider Settings (Issue #171)**: Omitted plaintext local proxy token from pi-ai provider descriptor and settings mutation payload, resolving authentication strictly through credential references.
20
+
21
+ ## [0.5.7] - 2026-09-30
22
+
23
+ ### Fixed
24
+ - **Active Account Pinning & Account Identity Unification (Issue #161)**: Resolved issue where proxy router ignored manually pinned active account and routed requests to secondary keys. Unified account identification across `id`, `apiKeyEnv`, and `label` in session routing, round-robin fallback, and cooldown tracking.
25
+ - **Account Pool Quota Metadata Enrichment (Issue #162)**: Provided real-time quota window utilization metadata (`remainingPercent`, `percentUsed`, `resetsAt`) to least-used account routing strategy. Handled unknown/unprobed quotas safely to prevent routing bias towards exhausted accounts.
26
+ - **Pi-AI Provider Session Affinity Header Propagation (Issue #163)**: Enabled `sendSessionAffinityHeaders: true` and `sessionAffinityFormat: 'openrouter'` in `@earendil-works/pi-ai` provider and model compatibility options. Expanded proxy session identification to extract session IDs from `x-session-id`, `x-dsh-session-id`, `session-id`, `x-session-affinity`, and client body parameters, preserving conversation affinity.
27
+ - **Client SSE Disconnection Upstream Abort (Issue #164)**: Monitored client response `close` events during streaming. Aborts upstream fetch and cancels ReadableStream reader immediately when a client disconnects prematurely, avoiding resource waste and orphaned upstream requests. Guarded writes and cleaned up listeners reliably.
28
+ - **Decoupled Connect Timeout & Streaming Idle Watchdog (Issue #165)**: Separated initial HTTP connect / header timeout (`connectTimeoutMs`, default 15s) from active streaming idle timeout (`streamIdleTimeoutMs`, default 30s). Prevented healthy long SSE streams from being cut off by monolithic timeouts. Added rearmable idle watchdog that signals client errors on stalls and accurately accounts failed streams with `isError: true` in token analytics instead of false successes.
29
+
8
30
  ## [0.5.6] - 2026-09-30
9
31
 
10
32
  ### Security
@@ -1,4 +1,4 @@
1
- import { resolveKeyValue, usageCache, clearUsageCache, clearProbeCache, DEFAULT_API_KEY_ENV } from './cline-client.js'
1
+ import { resolveKeyValue, usageCache, clearUsageCache, clearProbeCache, DEFAULT_API_KEY_ENV, DEFAULT_BASE_URL, buildUsageCacheKey } from './cline-client.js'
2
2
 
3
3
  /**
4
4
  * Resolve all accounts in pool with their status and keys.
@@ -32,7 +32,42 @@ function toPlainSnapshot(cfg) {
32
32
  return out
33
33
  }
34
34
 
35
+ export function getAccountQuotaSnapshot(accValue, baseUrl = DEFAULT_BASE_URL) {
36
+ if (!accValue || typeof accValue !== 'string') return null
37
+ const cacheKey = buildUsageCacheKey(baseUrl, accValue)
38
+ const cached = usageCache.get(cacheKey)?.data
39
+ if (!cached?.windows) return null
40
+
41
+ const now = Date.now()
42
+ const evaluateWindow = (w) => {
43
+ if (!w || typeof w.percentUsed !== 'number') return null
44
+ if (w.resetsAt) {
45
+ const resetTime = new Date(w.resetsAt).getTime()
46
+ if (!Number.isNaN(resetTime) && now >= resetTime) {
47
+ return 0
48
+ }
49
+ }
50
+ return w.percentUsed
51
+ }
52
+
53
+ const fh = evaluateWindow(cached.windows.fiveHour)
54
+ const wk = evaluateWindow(cached.windows.weekly)
55
+ const mo = evaluateWindow(cached.windows.monthly)
56
+
57
+ const activePcts = [fh, wk, mo].filter((p) => typeof p === 'number')
58
+ const percentUsed = activePcts.length > 0 ? Math.max(...activePcts) : null
59
+
60
+ return {
61
+ percentUsed,
62
+ fiveHour: fh,
63
+ weekly: wk,
64
+ monthly: mo,
65
+ raw: cached,
66
+ }
67
+ }
68
+
35
69
  export async function resolveAccountPool(ctx, cfg) {
70
+ const baseUrl = textSetting(cfg?.baseUrl) || DEFAULT_BASE_URL
36
71
  const apiKeyEnv = textSetting(cfg?.apiKeyEnv) || DEFAULT_API_KEY_ENV
37
72
  const defaultSlot = {
38
73
  id: 'default',
@@ -48,14 +83,21 @@ export async function resolveAccountPool(ctx, cfg) {
48
83
  const slot = allSlots[i]
49
84
  const envName = textSetting(slot.apiKeyEnv) || (i === 0 ? apiKeyEnv : `CLINEBOT_API_KEY_${i + 1}`)
50
85
  const keyInfo = await resolveKeyValue(ctx, envName)
86
+ const slotId = slot.id || (i === 0 ? 'default' : `account-${i + 1}`)
87
+ const isPinned = activeAccount === 'auto'
88
+ ? false
89
+ : (activeAccount ? (activeAccount === envName || activeAccount === slotId) : i === 0)
90
+ const quota = keyInfo.value ? getAccountQuotaSnapshot(keyInfo.value, baseUrl) : null
51
91
  resolved.push({
52
- id: slot.id || (i === 0 ? 'default' : `account-${i + 1}`),
92
+ id: slotId,
53
93
  label: textSetting(slot.label) || (i === 0 ? 'Default' : `Account ${i + 1}`),
54
94
  apiKeyEnv: envName,
55
95
  present: Boolean(keyInfo.value),
56
96
  source: keyInfo.source,
57
97
  value: keyInfo.value,
58
- isPinned: activeAccount ? activeAccount === envName : i === 0,
98
+ isPinned,
99
+ percentUsed: typeof quota?.percentUsed === 'number' ? quota.percentUsed : null,
100
+ quota,
59
101
  })
60
102
  }
61
103
 
@@ -87,6 +129,7 @@ export function isAccountQuotaExhausted(usage) {
87
129
  * Prioritizes accounts with lowest percentUsed and respects resetsAt recovery.
88
130
  */
89
131
  export async function rotateToNextAccount(ctx, cfg, reason = 'rate_limit', settingsApi = null) {
132
+ const baseUrl = textSetting(cfg?.baseUrl) || DEFAULT_BASE_URL
90
133
  const pool = await resolveAccountPool(ctx, cfg)
91
134
  const configured = pool.filter((acc) => acc.present && acc.value)
92
135
  if (configured.length <= 1) {
@@ -107,7 +150,7 @@ export async function rotateToNextAccount(ctx, cfg, reason = 'rate_limit', setti
107
150
  let lowestUsagePct = Infinity
108
151
 
109
152
  for (const cand of candidates) {
110
- const cacheKey = `cline:usage:${cand.value.slice(-8)}`
153
+ const cacheKey = buildUsageCacheKey(baseUrl, cand.value)
111
154
  const cached = usageCache.get(cacheKey)?.data
112
155
  const isExhausted = isAccountQuotaExhausted(cached)
113
156
 
package/lib/client.js CHANGED
@@ -1609,10 +1609,19 @@ function StatsSection({ status, onResetStats, busy, t }) {
1609
1609
  const [confirmReset, setConfirmReset] = React.useState(false)
1610
1610
 
1611
1611
  const s = status?.statsSummary || {}
1612
+ const totals = s.totals || {}
1612
1613
  const sess = status?.sessionStats || {}
1613
1614
 
1615
+ const requests = typeof totals.requests === 'number' ? totals.requests : (typeof s.requests === 'number' ? s.requests : 0)
1616
+ const totalTokens = typeof totals.totalTokens === 'number' ? totals.totalTokens : (typeof s.totalTokens === 'number' ? s.totalTokens : 0)
1617
+ const errors429 = typeof totals.rateLimited429 === 'number' ? totals.rateLimited429 : (typeof s.errors429 === 'number' ? s.errors429 : (typeof totals.failed === 'number' ? totals.failed : 0))
1618
+
1614
1619
  const topModels = Object.entries(s.byModel || {})
1615
- .sort((a, b) => (b[1].tokens || 0) - (a[1].tokens || 0))
1620
+ .sort((a, b) => {
1621
+ const bTokens = Number(b[1]?.totalTokens ?? b[1]?.tokens ?? 0)
1622
+ const aTokens = Number(a[1]?.totalTokens ?? a[1]?.tokens ?? 0)
1623
+ return bTokens - aTokens
1624
+ })
1616
1625
  .slice(0, 5)
1617
1626
 
1618
1627
  return React.createElement(
@@ -1674,19 +1683,19 @@ function StatsSection({ status, onResetStats, busy, t }) {
1674
1683
  React.createElement(
1675
1684
  'div',
1676
1685
  { className: 'cb-stat-box' },
1677
- React.createElement('div', { className: 'cb-stat-val' }, `${s.requests || 0} (${sess.successfulRequests || 0} sess)`),
1686
+ React.createElement('div', { className: 'cb-stat-val' }, `${requests} (${sess.successfulRequests || 0} sess)`),
1678
1687
  React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.requests'))
1679
1688
  ),
1680
1689
  React.createElement(
1681
1690
  'div',
1682
1691
  { className: 'cb-stat-box' },
1683
- React.createElement('div', { className: 'cb-stat-val' }, `${Number(s.totalTokens || 0).toLocaleString()}`),
1692
+ React.createElement('div', { className: 'cb-stat-val' }, `${Number(totalTokens).toLocaleString()}`),
1684
1693
  React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.tokens'))
1685
1694
  ),
1686
1695
  React.createElement(
1687
1696
  'div',
1688
1697
  { className: 'cb-stat-box' },
1689
- React.createElement('div', { className: 'cb-stat-val' }, `${s.errors429 || 0}`),
1698
+ React.createElement('div', { className: 'cb-stat-val' }, `${errors429}`),
1690
1699
  React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.errors429'))
1691
1700
  ),
1692
1701
  React.createElement(
@@ -1719,7 +1728,7 @@ function StatsSection({ status, onResetStats, busy, t }) {
1719
1728
  },
1720
1729
  },
1721
1730
  React.createElement('span', { style: { fontFamily: 'monospace' } }, modId),
1722
- React.createElement('span', null, `${data.requests || 0} reqs · ${Number(data.tokens || 0).toLocaleString()} tok`)
1731
+ React.createElement('span', null, `${data.requests || 0} reqs · ${Number(data.totalTokens ?? data.tokens ?? 0).toLocaleString()} tok`)
1723
1732
  )
1724
1733
  )
1725
1734
  )
@@ -1827,8 +1836,9 @@ function SettingsPage(props) {
1827
1836
  ])
1828
1837
  const data = await res.json().catch(() => ({}))
1829
1838
  if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
1830
- const statsData = statsRes && statsRes.ok ? await statsRes.json().catch(() => null) : null
1831
- setStatus({ ...data, statsSummary: statsData || data.statsSummary })
1839
+ const rawStats = statsRes && statsRes.ok ? await statsRes.json().catch(() => null) : null
1840
+ const statsObj = (rawStats && typeof rawStats === 'object' && rawStats.stats) ? rawStats.stats : (rawStats || data.statsSummary || {})
1841
+ setStatus({ ...data, statsSummary: statsObj })
1832
1842
  setDraft(data.config || {})
1833
1843
  }, [])
1834
1844
 
@@ -2058,14 +2068,15 @@ function SettingsPage(props) {
2058
2068
  }
2059
2069
 
2060
2070
  async function handleConfigPatch(patch) {
2071
+ setDraft((d) => ({ ...(d || {}), ...(patch || {}) }))
2061
2072
  await performAction('patch-config', async () => {
2062
2073
  const res = await fetch(`${ROUTE_PREFIX}/config`, {
2063
- method: 'POST',
2074
+ method: 'PUT',
2064
2075
  headers: { 'Content-Type': 'application/json' },
2065
- body: JSON.stringify(patch),
2076
+ body: JSON.stringify({ config: patch }),
2066
2077
  })
2067
2078
  const data = await res.json().catch(() => ({}))
2068
- if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
2079
+ if (!res.ok || data.ok === false) throw new Error(data.error || `HTTP ${res.status}`)
2069
2080
  await load()
2070
2081
  })
2071
2082
  }
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto'
1
2
  export {
2
3
  toCredentialRef,
3
4
  resolveApiKey,
@@ -26,6 +27,7 @@ export const DEFAULT_BASE_URL = 'https://api.cline.bot/api/v1'
26
27
  export const DEFAULT_API_KEY_ENV = 'CLINEBOT_API_KEY'
27
28
  export const DEFAULT_TIMEOUT_MS = 15000
28
29
  export const DEFAULT_SMOKE_TIMEOUT_MS = 25000
30
+ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 30000
29
31
  const USAGE_CACHE_TTL_MS = 60000
30
32
 
31
33
  export { PROVIDER_ID, PROVIDER_DISPLAY_NAME, DEFAULT_MODEL_ID }
@@ -86,8 +88,32 @@ export function clearProbeCache() {
86
88
  probeCache.clear()
87
89
  }
88
90
 
89
- export function clearUsageCache() {
90
- usageCache.clear()
91
+ /**
92
+ * Build an opaque, collision-free cache key from normalized baseUrl and full apiKey.
93
+ * Never leaks the raw apiKey or fingerprint into UI/logs.
94
+ *
95
+ * @param {string} baseUrl
96
+ * @param {string} apiKey
97
+ * @returns {string}
98
+ */
99
+ export function buildUsageCacheKey(baseUrl = '', apiKey = '') {
100
+ const normBase = normalizeBaseUrl(baseUrl)
101
+ const normKey = String(apiKey || '').trim()
102
+ if (!normKey) return ''
103
+ const hash = createHash('sha256')
104
+ .update(`${normBase}\0${normKey}`)
105
+ .digest('hex')
106
+ .slice(0, 32)
107
+ return `cline:usage:${hash}`
108
+ }
109
+
110
+ export function clearUsageCache(baseUrl, apiKey) {
111
+ if (baseUrl && apiKey) {
112
+ const key = buildUsageCacheKey(baseUrl, apiKey)
113
+ usageCache.delete(key)
114
+ } else {
115
+ usageCache.clear()
116
+ }
91
117
  }
92
118
 
93
119
  /**
@@ -105,6 +131,17 @@ export async function retryWithBackoff(fn, {
105
131
  const res = await fn()
106
132
  if (res && typeof res.status === 'number' && [429, 502, 503, 504].includes(res.status) && attempt < maxRetries) {
107
133
  attempt++
134
+ if (res.body) {
135
+ try {
136
+ if (typeof res.body.cancel === 'function') {
137
+ res.body.cancel().catch(() => {})
138
+ } else if (typeof res.body.destroy === 'function') {
139
+ res.body.destroy()
140
+ }
141
+ } catch {
142
+ /* ignore cancel error */
143
+ }
144
+ }
108
145
  const retryAfter = res.headers?.get ? Number(res.headers.get('retry-after')) * 1000 : 0
109
146
  const waitTime = retryAfter > 0 ? Math.min(retryAfter, maxDelayMs) : delay
110
147
  await new Promise((r) => setTimeout(r, waitTime))
@@ -139,7 +176,7 @@ export async function fetchUsageLimits(baseUrl, apiKey, {
139
176
  return { ok: false, error: 'API key is missing' }
140
177
  }
141
178
 
142
- const cacheKey = `cline:usage:${apiKey.slice(-8)}`
179
+ const cacheKey = buildUsageCacheKey(base, apiKey)
143
180
  const now = Date.now()
144
181
 
145
182
  const doFetch = async () => {
@@ -449,15 +486,20 @@ export function buildPiAiProvider({
449
486
  baseUrl = DEFAULT_BASE_URL,
450
487
  apiKeyEnv = DEFAULT_API_KEY_ENV,
451
488
  apiKey,
489
+ dynamicModels = [],
490
+ modelContextOverrides = [],
452
491
  models = [],
453
492
  customModels = [],
454
493
  displayName = PROVIDER_DISPLAY_NAME,
455
494
  } = {}) {
456
- const allAvailable = getAllModels(customModels)
495
+ const allAvailable = getAllModels(dynamicModels, modelContextOverrides, customModels)
496
+ const customModelsMap = new Map((Array.isArray(customModels) ? customModels : []).filter(Boolean).map(cm => [cm.id, cm]))
457
497
  const modelList = (Array.isArray(models) && models.length ? models : allAvailable).map((m) => {
458
498
  let item
459
499
  if (typeof m === 'string') {
460
- item = allAvailable.find((x) => x.id === m) || { id: m, name: m }
500
+ const customMatch = customModelsMap.get(m)
501
+ const baseMatch = allAvailable.find((x) => x.id === m)
502
+ item = customMatch ? { ...baseMatch, ...customMatch } : (baseMatch || { id: m, name: m })
461
503
  } else {
462
504
  item = m
463
505
  }
@@ -486,13 +528,18 @@ export function buildPiAiProvider({
486
528
  if (validEfforts.includes(key)) efforts[key] = key === 'off' ? (v === undefined ? null : v) : (typeof v === 'string' && v.trim() ? v.trim() : key)
487
529
  }
488
530
  }
531
+ const modelCompat = {
532
+ sendSessionAffinityHeaders: true,
533
+ sessionAffinityFormat: 'openrouter',
534
+ }
489
535
  if (efforts && Object.keys(efforts).length) {
490
536
  if (!('off' in efforts)) efforts.off = null
491
537
  res.reasoningEfforts = efforts
492
- res.compat = { supportsReasoningEffort: true }
538
+ modelCompat.supportsReasoningEffort = true
493
539
  } else {
494
540
  res.reasoningEfforts = false
495
541
  }
542
+ res.compat = modelCompat
496
543
  return res
497
544
  })
498
545
 
@@ -501,8 +548,11 @@ export function buildPiAiProvider({
501
548
  api: 'openai-completions',
502
549
  baseURL: normalizeBaseUrl(baseUrl),
503
550
  apiKeyEnv: apiKeyEnv || DEFAULT_API_KEY_ENV,
504
- ...(apiKey ? { apiKey } : {}),
505
- compat: { supportsReasoningEffort: true },
551
+ compat: {
552
+ supportsReasoningEffort: true,
553
+ sendSessionAffinityHeaders: true,
554
+ sessionAffinityFormat: 'openrouter',
555
+ },
506
556
  models: modelList,
507
557
  }
508
558
  }
package/lib/config.js CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  DEFAULT_API_KEY_ENV,
5
5
  DEFAULT_TIMEOUT_MS,
6
6
  DEFAULT_SMOKE_TIMEOUT_MS,
7
+ DEFAULT_STREAM_IDLE_TIMEOUT_MS,
7
8
  normalizeBaseUrl,
8
9
  } from './cline-client.js'
9
10
  import {
@@ -76,6 +77,8 @@ export const Config = z.object({
76
77
  .description('HTTP probe timeout in milliseconds.').volatile(),
77
78
  smokeTimeoutMs: z.number().default(DEFAULT_SMOKE_TIMEOUT_MS)
78
79
  .description('Timeout for smoke chat completions in milliseconds.').volatile(),
80
+ streamIdleTimeoutMs: z.number().default(DEFAULT_STREAM_IDLE_TIMEOUT_MS)
81
+ .description('Idle timeout between SSE stream chunks in milliseconds.').volatile(),
79
82
  modelsCachePath: z.string().default('~/.dsh/clinebot-models-cache.json')
80
83
  .description('Local on-disk cache path for models snapshot.').volatile(),
81
84
  accounts: z.array(z.object({
@@ -170,6 +173,7 @@ export function publicConfig(cfg) {
170
173
  enabledModels: activeIds,
171
174
  timeoutMs: Number(cfg?.timeoutMs) || DEFAULT_TIMEOUT_MS,
172
175
  smokeTimeoutMs: Number(cfg?.smokeTimeoutMs) || DEFAULT_SMOKE_TIMEOUT_MS,
176
+ streamIdleTimeoutMs: Number(cfg?.streamIdleTimeoutMs) || DEFAULT_STREAM_IDLE_TIMEOUT_MS,
173
177
  modelsCachePath: String(cfg?.modelsCachePath || '~/.dsh/clinebot-models-cache.json'),
174
178
  accounts: Array.isArray(cfg?.accounts) ? cfg.accounts : [],
175
179
  activeAccount: String(cfg?.activeAccount || ''),
package/lib/index.js CHANGED
@@ -230,6 +230,7 @@ export function apply(ctx, config) {
230
230
  const is429 = failure.status === 429 || failure.code === 429 || failure.code === 'rate_limit_exceeded'
231
231
  const isExceeded = isQuotaExceededError(`${failure.message || ''} ${failure.code || ''}`)
232
232
 
233
+ const isProxy = publicConfig(live()).proxyMode !== false
233
234
  if (isError) {
234
235
  recordSessionRequest({
235
236
  latencyMs,
@@ -239,15 +240,17 @@ export function apply(ctx, config) {
239
240
  completionTokens,
240
241
  model: options?.model,
241
242
  })
242
- recordUsage({
243
- model: options?.model,
244
- promptTokens,
245
- completionTokens,
246
- totalTokens: promptTokens + completionTokens,
247
- isError: true,
248
- is429,
249
- statsPath: publicConfig(live()).statsPath,
250
- })
243
+ if (!isProxy) {
244
+ recordUsage({
245
+ model: options?.model,
246
+ promptTokens,
247
+ completionTokens,
248
+ totalTokens: promptTokens + completionTokens,
249
+ isError: true,
250
+ is429,
251
+ statsPath: publicConfig(live()).statsPath,
252
+ })
253
+ }
251
254
  if (is429 || isExceeded) {
252
255
  handleStream429()
253
256
  }
@@ -268,13 +271,15 @@ export function apply(ctx, config) {
268
271
  completionTokens,
269
272
  model: options?.model,
270
273
  })
271
- recordUsage({
272
- model: options?.model,
273
- promptTokens,
274
- completionTokens,
275
- totalTokens: promptTokens + completionTokens,
276
- statsPath: publicConfig(live()).statsPath,
277
- })
274
+ if (!isProxy) {
275
+ recordUsage({
276
+ model: options?.model,
277
+ promptTokens,
278
+ completionTokens,
279
+ totalTokens: promptTokens + completionTokens,
280
+ statsPath: publicConfig(live()).statsPath,
281
+ })
282
+ }
278
283
  }
279
284
  }
280
285
  yield chunk
@@ -287,13 +292,16 @@ export function apply(ctx, config) {
287
292
  completionTokens,
288
293
  model: options?.model,
289
294
  })
290
- recordUsage({
291
- model: options?.model,
292
- promptTokens,
293
- completionTokens,
294
- totalTokens: promptTokens + completionTokens,
295
- statsPath: publicConfig(live()).statsPath,
296
- })
295
+ const isProxy = publicConfig(live()).proxyMode !== false
296
+ if (!isProxy) {
297
+ recordUsage({
298
+ model: options?.model,
299
+ promptTokens,
300
+ completionTokens,
301
+ totalTokens: promptTokens + completionTokens,
302
+ statsPath: publicConfig(live()).statsPath,
303
+ })
304
+ }
297
305
  }
298
306
  } catch (err) {
299
307
  if (isCline) {
@@ -308,15 +316,18 @@ export function apply(ctx, config) {
308
316
  completionTokens,
309
317
  model: options?.model,
310
318
  })
311
- recordUsage({
312
- model: options?.model,
313
- promptTokens,
314
- completionTokens,
315
- totalTokens: promptTokens + completionTokens,
316
- isError: true,
317
- is429: status === 429,
318
- statsPath: publicConfig(live()).statsPath,
319
- })
319
+ const isProxy = publicConfig(live()).proxyMode !== false
320
+ if (!isProxy) {
321
+ recordUsage({
322
+ model: options?.model,
323
+ promptTokens,
324
+ completionTokens,
325
+ totalTokens: promptTokens + completionTokens,
326
+ isError: true,
327
+ is429: status === 429,
328
+ statsPath: publicConfig(live()).statsPath,
329
+ })
330
+ }
320
331
  if (status === 429 || isQuotaExceededError(msg)) {
321
332
  handleStream429()
322
333
  }
package/lib/models.js CHANGED
@@ -422,10 +422,10 @@ export function getAllModels(dynamicModels = [], modelContextOverrides = [], cus
422
422
  description: cm.description || `Custom model ${cm.name || cm.id}`,
423
423
  contextLength: cm.contextLength || 128000,
424
424
  maxTokens: cm.maxTokens || 8192,
425
- input: ['text'],
425
+ input: Array.isArray(cm.input) && cm.input.length ? cm.input : ['text'],
426
426
  category: cm.category || 'general',
427
427
  isCustom: true,
428
- reasoningEfforts: cm.isReasoning ? ['low', 'medium', 'high', 'max'] : undefined,
428
+ reasoningEfforts: cm.reasoningEfforts !== undefined ? cm.reasoningEfforts : (cm.isReasoning ? ['low', 'medium', 'high', 'max'] : undefined),
429
429
  unverified: false,
430
430
  }
431
431
  baseModels.push(applyOverride(fullCustom))
@@ -437,19 +437,19 @@ export function getAllModels(dynamicModels = [], modelContextOverrides = [], cus
437
437
  return baseModels
438
438
  }
439
439
 
440
- function findModel(id, dynamicModels = [], modelContextOverrides = [], customModels = []) {
441
- const all = getAllModels(dynamicModels, modelContextOverrides)
440
+ export function findModel(id, dynamicModels = [], modelContextOverrides = [], customModels = []) {
441
+ const all = getAllModels(dynamicModels, modelContextOverrides, customModels)
442
442
  const found = all.find((m) => m.id === id)
443
443
  if (found) return found
444
444
  return CLINE_MODELS.find((m) => m.id === id) || null
445
445
  }
446
446
 
447
447
  export function isSupportedModel(id, dynamicModels = [], modelContextOverrides = [], customModels = []) {
448
- return findModel(id, dynamicModels, modelContextOverrides) !== null
448
+ return findModel(id, dynamicModels, modelContextOverrides, customModels) !== null
449
449
  }
450
450
 
451
- function getDefaultModelIds(dynamicModels = [], modelContextOverrides = [], customModels = []) {
452
- return getAllModels(dynamicModels, modelContextOverrides).map((m) => m.id)
451
+ export function getDefaultModelIds(dynamicModels = [], modelContextOverrides = [], customModels = []) {
452
+ return getAllModels(dynamicModels, modelContextOverrides, customModels).map((m) => m.id)
453
453
  }
454
454
 
455
455
  /**
@@ -501,8 +501,8 @@ export function formatModelDescription(model) {
501
501
  /**
502
502
  * Check if a model supports vision input.
503
503
  */
504
- export function isVisionModel(id, dynamicModels = [], modelContextOverrides = []) {
505
- const m = findModel(id, dynamicModels, modelContextOverrides)
504
+ export function isVisionModel(id, dynamicModels = [], modelContextOverrides = [], customModels = []) {
505
+ const m = findModel(id, dynamicModels, modelContextOverrides, customModels)
506
506
  if (!m) return false
507
507
  return Boolean(m.input?.includes('image') || m.input?.includes('vision'))
508
508
  }
@@ -548,3 +548,29 @@ export async function loadModelsDiskCache(cachePath) {
548
548
  return null
549
549
  }
550
550
  }
551
+
552
+ /**
553
+ * Detects whether the authoritative dynamic models catalog differs from the current catalog
554
+ * in model count, IDs, or metadata (e.g. contextLength, maxTokens, category, etc.).
555
+ */
556
+ export function isCatalogDifferent(currentList = [], authoritativeList = []) {
557
+ if (!Array.isArray(currentList) || !Array.isArray(authoritativeList)) return true
558
+ if (currentList.length !== authoritativeList.length) return true
559
+ const currentMap = new Map(currentList.map((m) => [m.id, m]))
560
+ for (const auth of authoritativeList) {
561
+ const cur = currentMap.get(auth.id)
562
+ if (!cur) return true
563
+ if (
564
+ cur.name !== auth.name ||
565
+ cur.contextLength !== auth.contextLength ||
566
+ cur.maxTokens !== auth.maxTokens ||
567
+ cur.category !== auth.category ||
568
+ cur.description !== auth.description ||
569
+ JSON.stringify(cur.input || []) !== JSON.stringify(auth.input || []) ||
570
+ JSON.stringify(cur.reasoningEfforts || []) !== JSON.stringify(auth.reasoningEfforts || [])
571
+ ) {
572
+ return true
573
+ }
574
+ }
575
+ return false
576
+ }
@@ -1,3 +1,4 @@
1
+ import { getStatsSummary } from './stats-storage.js'
1
2
  import os from 'node:os'
2
3
  import path from 'node:path'
3
4
  import {
@@ -8,6 +9,7 @@ import {
8
9
  loadModelsDiskCache,
9
10
  OBSOLETE_MODEL_ID_MAP,
10
11
  migrateModelEntry,
12
+ isCatalogDifferent,
11
13
  } from './models.js'
12
14
  import {
13
15
  probeHealth,
@@ -161,9 +163,6 @@ export async function buildStatus(ctx, cfg) {
161
163
  source: activeAcc.source || key.source,
162
164
  },
163
165
  accounts: pool.map((acc) => {
164
- const cacheKey = `cline:usage:${(acc.value || '').slice(-8)}`
165
- const cached = usageCache.get(cacheKey)?.data
166
- const pct = cached?.windows?.weekly?.percentUsed ?? cached?.windows?.monthly?.percentUsed ?? cached?.windows?.fiveHour?.percentUsed ?? null
167
166
  return {
168
167
  id: acc.id,
169
168
  label: acc.label,
@@ -171,7 +170,7 @@ export async function buildStatus(ctx, cfg) {
171
170
  present: acc.present,
172
171
  source: acc.source,
173
172
  isPinned: acc.isPinned,
174
- percentUsed: typeof pct === 'number' ? pct : null,
173
+ percentUsed: typeof acc.percentUsed === 'number' ? acc.percentUsed : null,
175
174
  }
176
175
  }),
177
176
  activeAccount: activeAcc.apiKeyEnv,
@@ -179,6 +178,7 @@ export async function buildStatus(ctx, cfg) {
179
178
  usage: publicUsage(usage),
180
179
  quotaWarning,
181
180
  sessionStats: { ...sessionStats },
181
+ statsSummary: getStatsSummary(),
182
182
  lastRotation: getLastRotation(),
183
183
  isRegistered,
184
184
  availableModels: allModels,
@@ -216,9 +216,10 @@ export async function upsertPiAiProvider(ctx, cfg, activeModelIds) {
216
216
  const providerObj = buildPiAiProvider({
217
217
  baseUrl: effectiveBaseUrl,
218
218
  apiKeyEnv: isProxy ? LOCAL_PROXY_KEY_ENV : (activeAcc?.apiKeyEnv || pub.apiKeyEnv),
219
- apiKey: localToken,
220
219
  models: modelsToRegister.length ? modelsToRegister : allModels,
221
- customModels: [...(pub.dynamicModels || []), ...(pub.customModels || [])],
220
+ dynamicModels: pub.dynamicModels,
221
+ modelContextOverrides: pub.modelContextOverrides,
222
+ customModels: pub.customModels,
222
223
  displayName: PROVIDER_DISPLAY_NAME,
223
224
  })
224
225
 
@@ -304,12 +305,11 @@ export async function autoDiscoverPlanModels(ctx, { live, getSettingsApi, syncPr
304
305
  bypassCache: true,
305
306
  })
306
307
 
307
- if (usageData?.ok && Array.isArray(usageData.dynamicModels) && usageData.dynamicModels.length > 0) {
308
+ if (usageData?.ok && Array.isArray(usageData.dynamicModels)) {
308
309
  const existingDynamic = pub.dynamicModels || []
309
- const existingIds = new Set(existingDynamic.map((m) => m.id))
310
- const hasNew = usageData.dynamicModels.some((m) => !existingIds.has(m.id))
310
+ const isDifferent = isCatalogDifferent(existingDynamic, usageData.dynamicModels)
311
311
 
312
- if (hasNew && settingsApi?.replace) {
312
+ if (isDifferent && settingsApi?.replace) {
313
313
  const now = Date.now()
314
314
  const next = plainConfig(Config({
315
315
  ...plainConfig(live()),
@@ -33,7 +33,7 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
33
33
  }
34
34
 
35
35
  const dynamicModels = Array.isArray(usageData.dynamicModels) ? usageData.dynamicModels : []
36
- const allModels = getAllModels(dynamicModels, pub.modelContextOverrides)
36
+ const allModels = getAllModels(dynamicModels, pub.modelContextOverrides, pub.customModels)
37
37
  const settingsApi = getSettingsApi()
38
38
  if (!settingsApi || typeof settingsApi.replace !== 'function') {
39
39
  return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
@@ -87,7 +87,7 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
87
87
  if (Array.isArray(body.disabledModels)) {
88
88
  patch.disabledModels = body.disabledModels
89
89
  } else if (Array.isArray(body.enabledModels)) {
90
- const allModels = getAllModels(pub.dynamicModels, pub.modelContextOverrides)
90
+ const allModels = getAllModels(pub.dynamicModels, pub.modelContextOverrides, pub.customModels)
91
91
  const enabledSet = new Set(body.enabledModels)
92
92
  patch.disabledModels = allModels.map((m) => m.id).filter((id) => !enabledSet.has(id))
93
93
  }
@@ -120,7 +120,7 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
120
120
  }
121
121
 
122
122
  const pub = publicConfig(live())
123
- const allModels = getAllModels(pub.dynamicModels, [])
123
+ const allModels = getAllModels(pub.dynamicModels, [], pub.customModels)
124
124
  let currentOverrides = Array.isArray(pub.modelContextOverrides) ? [...pub.modelContextOverrides] : []
125
125
 
126
126
  if (body.mode === 'all-original') {
@@ -171,7 +171,7 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
171
171
  await settingsApi.replace(next)
172
172
  await syncProviderState(next)
173
173
 
174
- const updatedModels = getAllModels(pub.dynamicModels, currentOverrides)
174
+ const updatedModels = getAllModels(pub.dynamicModels, currentOverrides, pub.customModels)
175
175
  return writeJson(res, 200, {
176
176
  ok: true,
177
177
  modelContextOverrides: next.modelContextOverrides,
@@ -106,6 +106,11 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
106
106
  const sessionId = String(
107
107
  req.headers['x-dsh-session-id'] ||
108
108
  req.headers['x-session-id'] ||
109
+ req.headers['session-id'] ||
110
+ req.headers['session_id'] ||
111
+ req.headers['x-session-affinity'] ||
112
+ req.headers['x-client-request-id'] ||
113
+ body?.session_id ||
109
114
  body?.conversation_id ||
110
115
  body?.chat_id ||
111
116
  body?.user ||
@@ -127,18 +132,45 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
127
132
  const isStreaming = Boolean(body.stream)
128
133
  const configuredAccounts = pool.filter(a => a.present && a.value)
129
134
 
135
+ const clientAbortCtrl = new AbortController()
136
+ let clientClosed = false
137
+ const onClientClose = () => {
138
+ clientClosed = true
139
+ if (!res.writableFinished) {
140
+ clientAbortCtrl.abort()
141
+ }
142
+ }
143
+ if (typeof res.on === 'function') res.on('close', onClientClose)
144
+ if (typeof req.on === 'function') req.on('close', onClientClose)
145
+
146
+ const cleanupClientListeners = () => {
147
+ if (typeof res.off === 'function') res.off('close', onClientClose)
148
+ else if (typeof res.removeListener === 'function') res.removeListener('close', onClientClose)
149
+ if (typeof req.off === 'function') req.off('close', onClientClose)
150
+ else if (typeof req.removeListener === 'function') req.removeListener('close', onClientClose)
151
+ }
152
+
130
153
  const maxRetries = Math.max(1, configuredAccounts.length)
131
154
  let attempt = 0
132
155
  let lastError = null
133
156
  let hadTimeout = false
157
+ let lastTargetAccount = null
158
+ let lastRetryAfter = null
134
159
 
135
160
  while (attempt < maxRetries) {
136
161
  attempt += 1
137
162
 
163
+ if (clientAbortCtrl.signal.aborted || clientClosed) {
164
+ cleanupClientListeners()
165
+ return
166
+ }
167
+
138
168
  const routeResult = resolveSessionAccount(sessionId, pool, pub.activeAccount)
139
169
  const targetAccount = routeResult.account
170
+ lastTargetAccount = targetAccount
140
171
 
141
172
  if (!targetAccount || !targetAccount.value) {
173
+ cleanupClientListeners()
142
174
  return writeJson(res, 503, {
143
175
  error: {
144
176
  message: 'No available ClinePass API keys configured in pool',
@@ -156,16 +188,17 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
156
188
  'User-Agent': USER_AGENT
157
189
  }
158
190
 
159
- const upstreamTimeoutMs = Math.max(1000, Number(pub.timeoutMs) || 60000)
160
- const timeoutSignal = AbortSignal.timeout(upstreamTimeoutMs)
161
- const clientAbortCtrl = new AbortController()
162
- const onReqClose = () => {
163
- clientAbortCtrl.abort()
164
- }
165
- if (typeof req.on === 'function') {
166
- req.on('close', onReqClose)
167
- }
168
- const fetchSignal = AbortSignal.any([clientAbortCtrl.signal, timeoutSignal])
191
+ const connectTimeoutMs = Math.max(50, Number(pub.connectTimeoutMs || pub.timeoutMs) || 15000)
192
+ const streamIdleTimeoutMs = Math.max(50, Number(pub.streamIdleTimeoutMs || pub.idleTimeoutMs) || 30000)
193
+
194
+ const connectAbortCtrl = new AbortController()
195
+ let connectTimedOut = false
196
+ const connectTimer = setTimeout(() => {
197
+ connectTimedOut = true
198
+ connectAbortCtrl.abort(new Error(`Upstream connect timeout after ${connectTimeoutMs}ms`))
199
+ }, connectTimeoutMs)
200
+
201
+ const fetchSignal = AbortSignal.any([clientAbortCtrl.signal, connectAbortCtrl.signal])
169
202
 
170
203
  let upstreamRes
171
204
  try {
@@ -177,32 +210,46 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
177
210
  keepalive: true
178
211
  })
179
212
  } catch (fetchErr) {
180
- if (clientAbortCtrl.signal.aborted) {
181
- // Client closed connection, stop processing
213
+ clearTimeout(connectTimer)
214
+ if (clientAbortCtrl.signal.aborted || clientClosed) {
215
+ cleanupClientListeners()
182
216
  return
183
217
  }
184
218
  lastError = fetchErr
185
- const isTimeout = timeoutSignal.aborted || fetchErr.name === 'TimeoutError' || String(fetchErr).includes('timeout')
219
+ const isTimeout = connectTimedOut || connectAbortCtrl.signal.aborted || fetchErr.name === 'TimeoutError' || String(fetchErr).includes('timeout')
186
220
  if (isTimeout) {
187
221
  hadTimeout = true
188
- const accountId = targetAccount.id || targetAccount.label
222
+ const accountId = targetAccount.apiKeyEnv || targetAccount.id || targetAccount.label
189
223
  markAccountCooldown(accountId, 60000, null, 'upstream_timeout')
190
- ctx?.logger?.warn?.(`[dsh-clinebot:proxy] Account "${accountId}" upstream timed out after ${upstreamTimeoutMs}ms, attempting failover (attempt ${attempt}/${maxRetries})`)
224
+ if (targetAccount.id && targetAccount.id !== accountId) markAccountCooldown(targetAccount.id, 60000, null, 'upstream_timeout')
225
+ ctx?.logger?.warn?.(`[dsh-clinebot:proxy] Account "${accountId}" upstream connect timed out after ${connectTimeoutMs}ms, attempting failover (attempt ${attempt}/${maxRetries})`)
191
226
  }
192
227
  continue
193
228
  }
229
+ clearTimeout(connectTimer)
194
230
 
195
231
  // If upstream rejected with 429 or 402 quota exhaustion before streaming, failover!
196
232
  if (upstreamRes.status === 429 || upstreamRes.status === 402) {
197
- const accountId = targetAccount.id || targetAccount.label
233
+ const accountId = targetAccount.apiKeyEnv || targetAccount.id || targetAccount.label
198
234
  markAccountCooldown(accountId, 60000, null, 'upstream_429')
199
- recordUsage({
200
- model: body.model,
201
- accountId,
202
- is429: true,
203
- isError: true,
204
- statsPath: pub.statsPath
205
- })
235
+ if (targetAccount.id && targetAccount.id !== accountId) markAccountCooldown(targetAccount.id, 60000, null, 'upstream_429')
236
+ lastError = new Error(`Upstream rejected with HTTP ${upstreamRes.status}`)
237
+ lastError.status = upstreamRes.status
238
+ const retryAfter = upstreamRes.headers?.get?.('retry-after')
239
+ if (retryAfter) {
240
+ lastRetryAfter = retryAfter
241
+ }
242
+ if (upstreamRes.body) {
243
+ try {
244
+ if (typeof upstreamRes.body.cancel === 'function') {
245
+ upstreamRes.body.cancel().catch(() => {})
246
+ } else if (typeof upstreamRes.body.destroy === 'function') {
247
+ upstreamRes.body.destroy()
248
+ }
249
+ } catch {
250
+ /* ignore cancel error */
251
+ }
252
+ }
206
253
  ctx?.logger?.warn?.(`[dsh-clinebot:proxy] Account "${accountId}" hit 429, attempting failover (attempt ${attempt}/${maxRetries})`)
207
254
  continue
208
255
  }
@@ -221,6 +268,8 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
221
268
  let completionTokens = 0
222
269
  let totalTokens = 0
223
270
  let buffer = ''
271
+ let streamError = null
272
+ let streamCompletedCleanly = false
224
273
 
225
274
  const reader = upstreamRes.body.getReader()
226
275
  const decoder = new TextDecoder('utf8')
@@ -229,22 +278,67 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
229
278
  const onStreamAbort = () => {
230
279
  streamAborted = true
231
280
  try {
232
- reader.cancel().catch(() => {})
233
- } catch {
234
- /* ignore */
281
+ reader.cancel('client closed response').catch(() => {})
282
+ } catch { /* reader already cancelled or closed */ }
283
+ }
284
+
285
+ if (clientAbortCtrl.signal.aborted || clientClosed) {
286
+ onStreamAbort()
287
+ } else {
288
+ clientAbortCtrl.signal.addEventListener('abort', onStreamAbort, { once: true })
289
+ }
290
+
291
+ let idleTimer = null
292
+ let idleTimedOut = false
293
+ const resetIdleTimer = () => {
294
+ if (idleTimer) clearTimeout(idleTimer)
295
+ idleTimer = setTimeout(() => {
296
+ idleTimedOut = true
297
+ streamError = new Error(`Stream idle timeout after ${streamIdleTimeoutMs}ms`)
298
+ try {
299
+ reader.cancel('stream idle timeout').catch(() => {})
300
+ } catch { /* reader already cancelled or closed */ }
301
+ }, streamIdleTimeoutMs)
302
+ }
303
+
304
+ const clearIdleTimer = () => {
305
+ if (idleTimer) {
306
+ clearTimeout(idleTimer)
307
+ idleTimer = null
235
308
  }
236
309
  }
237
- if (typeof req.on === 'function') {
238
- req.on('close', onStreamAbort)
310
+
311
+ let totalTimer = null
312
+ if (pub.streamTotalTimeoutMs && Number(pub.streamTotalTimeoutMs) > 0) {
313
+ const totalMs = Number(pub.streamTotalTimeoutMs)
314
+ totalTimer = setTimeout(() => {
315
+ streamError = new Error(`Stream total timeout after ${totalMs}ms`)
316
+ try {
317
+ reader.cancel('stream total timeout').catch(() => {})
318
+ } catch { /* reader already cancelled or closed */ }
319
+ }, totalMs)
239
320
  }
240
321
 
241
322
  try {
323
+ resetIdleTimer()
242
324
  while (true) {
325
+ if (streamAborted || clientAbortCtrl.signal.aborted || clientClosed) break
243
326
  const { done, value } = await reader.read()
244
- if (done || streamAborted) break
327
+ if (idleTimedOut || streamError) break
328
+ if (done || streamAborted) {
329
+ if (done && !streamAborted && !clientClosed && !idleTimedOut && !streamError) {
330
+ streamCompletedCleanly = true
331
+ }
332
+ break
333
+ }
334
+
335
+ resetIdleTimer()
245
336
 
246
337
  const chunkStr = decoder.decode(value, { stream: true })
247
- res.write(value)
338
+ if (!res.destroyed && !res.writableEnded) {
339
+ res.write(value)
340
+ }
341
+ if (streamAborted || clientAbortCtrl.signal.aborted || clientClosed) break
248
342
 
249
343
  // Parse SSE lines for usage metrics
250
344
  buffer += chunkStr
@@ -268,14 +362,34 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
268
362
  }
269
363
  }
270
364
  } catch (streamErr) {
365
+ streamError = streamErr
271
366
  ctx?.logger?.warn?.('[dsh-clinebot:proxy] Stream read error: ' + (streamErr?.message || streamErr))
272
367
  } finally {
368
+ clearIdleTimer()
369
+ if (totalTimer) clearTimeout(totalTimer)
370
+ clientAbortCtrl.signal.removeEventListener('abort', onStreamAbort)
371
+ cleanupClientListeners()
273
372
  try {
274
373
  reader.releaseLock()
275
- } catch (err) {
276
- /* ignore lock release error if reader already closed */
374
+ } catch (err) { /* ignore lock release error if reader closed */ }
375
+
376
+ const isStreamFailed = Boolean(streamError || idleTimedOut || clientClosed || clientAbortCtrl.signal.aborted || !streamCompletedCleanly)
377
+
378
+ if (!res.destroyed && !res.writableEnded) {
379
+ if (isStreamFailed && (idleTimedOut || streamError)) {
380
+ try {
381
+ const errPayload = JSON.stringify({
382
+ error: {
383
+ message: streamError?.message || 'Stream interrupted or timed out',
384
+ type: 'stream_error',
385
+ code: idleTimedOut ? 'stream_idle_timeout' : 'stream_error'
386
+ }
387
+ })
388
+ res.write(`data: ${errPayload}\n\n`)
389
+ } catch { /* reader already cancelled or closed */ }
390
+ }
391
+ res.end()
277
392
  }
278
- res.end()
279
393
 
280
394
  recordUsage({
281
395
  model: body.model,
@@ -283,6 +397,7 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
283
397
  promptTokens,
284
398
  completionTokens,
285
399
  totalTokens: totalTokens || (promptTokens + completionTokens),
400
+ isError: isStreamFailed,
286
401
  statsPath: pub.statsPath
287
402
  })
288
403
  }
@@ -298,17 +413,16 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
298
413
  resJson = null
299
414
  }
300
415
 
301
- if (resJson?.usage) {
302
- recordUsage({
303
- model: body.model,
304
- accountId: targetAccount.id || targetAccount.label,
305
- promptTokens: resJson.usage.prompt_tokens,
306
- completionTokens: resJson.usage.completion_tokens,
307
- totalTokens: resJson.usage.total_tokens,
308
- isError: upstreamRes.status >= 400,
309
- statsPath: pub.statsPath
310
- })
311
- }
416
+ cleanupClientListeners()
417
+ recordUsage({
418
+ model: body.model,
419
+ accountId: targetAccount.id || targetAccount.label,
420
+ promptTokens: resJson?.usage?.prompt_tokens || 0,
421
+ completionTokens: resJson?.usage?.completion_tokens || 0,
422
+ totalTokens: resJson?.usage?.total_tokens || 0,
423
+ isError: upstreamRes.status >= 400,
424
+ statsPath: pub.statsPath
425
+ })
312
426
 
313
427
  res.statusCode = upstreamRes.status
314
428
  res.setHeader('Content-Type', 'application/json; charset=utf-8')
@@ -316,22 +430,49 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
316
430
  return
317
431
  }
318
432
 
433
+ cleanupClientListeners()
319
434
  // Exhausted all retries
435
+ const is429Exhausted = !hadTimeout && (lastError?.status === 429 || lastError?.status === 402)
436
+ recordUsage({
437
+ model: body.model,
438
+ accountId: lastTargetAccount?.id || lastTargetAccount?.label || 'default',
439
+ isError: true,
440
+ is429: is429Exhausted,
441
+ statsPath: pub.statsPath
442
+ })
320
443
  if (hadTimeout) {
321
444
  return writeJson(res, 504, {
322
445
  error: {
323
446
  message: 'Gateway Timeout: upstream provider timed out across all available accounts',
324
447
  type: 'gateway_timeout',
325
- code: 'upstream_timeout'
448
+ code: 'upstream_timeout',
449
+ details: lastError?.message || null
450
+ }
451
+ })
452
+ }
453
+
454
+ if (is429Exhausted) {
455
+ if (lastRetryAfter) {
456
+ res.setHeader('Retry-After', lastRetryAfter)
457
+ }
458
+ return writeJson(res, 429, {
459
+ error: {
460
+ message: 'All configured accounts in pool exceeded rate limits or quota',
461
+ type: 'rate_limit_exceeded',
462
+ code: 'rate_limit_exceeded',
463
+ details: lastError?.message || null
326
464
  }
327
465
  })
328
466
  }
329
467
 
330
- writeJson(res, 429, {
468
+ const status = typeof lastError?.status === 'number' && lastError.status >= 500 && lastError.status <= 599
469
+ ? lastError.status
470
+ : 502
471
+ return writeJson(res, status, {
331
472
  error: {
332
- message: 'All configured accounts in pool exceeded rate limits or quota',
333
- type: 'rate_limit_exceeded',
334
- code: 'rate_limit_exceeded',
473
+ message: `Bad Gateway: upstream network error or unavailable: ${lastError?.message || 'unknown error'}`,
474
+ type: 'bad_gateway',
475
+ code: 'upstream_unavailable',
335
476
  details: lastError?.message || null
336
477
  }
337
478
  })
@@ -101,6 +101,20 @@ export function clearAccountCooldown(accountId) {
101
101
  }
102
102
  }
103
103
 
104
+ export function matchesAccount(acc, identifier) {
105
+ if (!acc || !identifier) return false
106
+ return acc.id === identifier || acc.apiKeyEnv === identifier || acc.label === identifier
107
+ }
108
+
109
+ export function isAccountBlocked(acc) {
110
+ if (!acc) return false
111
+ return Boolean(
112
+ (acc.id && isAccountInCooldown(acc.id)) ||
113
+ (acc.apiKeyEnv && isAccountInCooldown(acc.apiKeyEnv)) ||
114
+ (acc.label && isAccountInCooldown(acc.label))
115
+ )
116
+ }
117
+
104
118
  /**
105
119
  * Select the best account from the pool based on least-used quota.
106
120
  * Lowest percentUsed is prioritized.
@@ -108,6 +122,14 @@ export function clearAccountCooldown(accountId) {
108
122
  * @param {Array<Object>} accounts Pool of accounts
109
123
  * @returns {Object|null} Selected account or null
110
124
  */
125
+ export function getAccountUsageMetric(acc) {
126
+ if (typeof acc?.percentUsed === 'number') {
127
+ return acc.percentUsed
128
+ }
129
+ // Unknown quota is treated as 50% neutral midpoint rather than assuming 0% completely free
130
+ return 50
131
+ }
132
+
111
133
  export function selectLeastUsedAccount(accounts = []) {
112
134
  if (!Array.isArray(accounts) || accounts.length === 0) {
113
135
  return null
@@ -116,7 +138,7 @@ export function selectLeastUsedAccount(accounts = []) {
116
138
  // Filter available accounts that have keys present and are not in cooldown
117
139
  const available = accounts.filter(acc => {
118
140
  if (!acc || acc.present === false) return false
119
- return !isAccountInCooldown(acc.id || acc.label)
141
+ return !isAccountBlocked(acc)
120
142
  })
121
143
 
122
144
  const candidatePool = available.length > 0 ? available : accounts.filter(a => a && a.present !== false)
@@ -124,10 +146,10 @@ export function selectLeastUsedAccount(accounts = []) {
124
146
  return accounts[0] || null
125
147
  }
126
148
 
127
- // Sort by lowest percentUsed (least used). If percentUsed is null/undefined, treat as 0% used
149
+ // Sort by lowest percentUsed (least used). Unknown quota is treated as neutral 50%
128
150
  const sorted = [...candidatePool].sort((a, b) => {
129
- const aUsed = typeof a.percentUsed === 'number' ? a.percentUsed : 0
130
- const bUsed = typeof b.percentUsed === 'number' ? b.percentUsed : 0
151
+ const aUsed = getAccountUsageMetric(a)
152
+ const bUsed = getAccountUsageMetric(b)
131
153
  return aUsed - bUsed
132
154
  })
133
155
 
@@ -139,50 +161,85 @@ export function selectLeastUsedAccount(accounts = []) {
139
161
  *
140
162
  * @param {string|null} sessionId Unique session or conversation identifier
141
163
  * @param {Array<Object>} accounts Configured accounts pool
142
- * @param {string} [defaultActiveId] Fallback active account ID
164
+ * @param {string} [defaultActiveId] Explicit active account ID, envName or 'auto'
143
165
  * @returns {{ account: Object|null, isReassigned: boolean, reason: string }}
144
166
  */
145
167
  export function resolveSessionAccount(sessionId, accounts = [], defaultActiveId = '') {
146
- const safeAccounts = Array.isArray(accounts) ? accounts : []
168
+ const safeAccounts = Array.isArray(accounts) ? accounts.filter(a => a && a.present !== false) : []
147
169
  if (safeAccounts.length === 0) {
148
- return { account: null, isReassigned: false, reason: 'empty_pool' }
170
+ const fallback = Array.isArray(accounts) && accounts.length > 0 ? accounts[0] : null
171
+ return { account: fallback, isReassigned: false, reason: 'empty_pool' }
149
172
  }
150
173
 
151
- // If no sessionId is provided, use least-used account or default active
174
+ // Determine explicit pin / active account policy
175
+ const isAutoMode = defaultActiveId === 'auto'
176
+ let pinnedAccount = null
177
+ if (!isAutoMode) {
178
+ if (defaultActiveId) {
179
+ pinnedAccount = safeAccounts.find(a => matchesAccount(a, defaultActiveId)) || null
180
+ }
181
+ if (!pinnedAccount) {
182
+ pinnedAccount = safeAccounts.find(a => a.isPinned) || null
183
+ }
184
+ }
185
+
186
+ const pinnedAvailable = pinnedAccount && !isAccountBlocked(pinnedAccount) ? pinnedAccount : null
187
+
188
+ // If no sessionId provided:
152
189
  if (!sessionId) {
153
- const best = selectLeastUsedAccount(safeAccounts) || safeAccounts.find(a => a.id === defaultActiveId) || safeAccounts[0]
154
- return { account: best, isReassigned: false, reason: 'no_session_id' }
190
+ if (pinnedAvailable) {
191
+ return { account: pinnedAvailable, isReassigned: false, reason: 'pinned_active' }
192
+ }
193
+ const best = selectLeastUsedAccount(safeAccounts) || safeAccounts[0]
194
+ return { account: best, isReassigned: false, reason: pinnedAccount ? 'pinned_in_cooldown_fallback' : 'no_session_id' }
155
195
  }
156
196
 
157
- let existing = sessions.get(sessionId)
158
197
  const now = Date.now()
198
+ let existing = sessions.get(sessionId)
199
+
159
200
  if (existing) {
160
- // TTL eviction check
201
+ // Check TTL
161
202
  if (now - existing.lastUsedAt > SESSION_TTL_MS) {
162
203
  sessions.delete(sessionId)
163
204
  existing = null
164
- } else {
165
- const assigned = safeAccounts.find(a => (a.id || a.label) === existing.accountId)
166
- // If assigned account is still valid and NOT in cooldown, keep sticky pin
167
- if (assigned && !isAccountInCooldown(assigned.id || assigned.label)) {
168
- existing.lastUsedAt = now
169
- return { account: assigned, isReassigned: false, reason: 'sticky_pinned' }
170
- }
171
- // Failover: account was deleted or entered cooldown mid-session
172
205
  }
173
206
  }
174
207
 
175
- // Prune before adding a brand new session if capacity reached
208
+ // If manual pin is configured and available:
209
+ if (pinnedAvailable) {
210
+ const reassigned = Boolean(existing && !matchesAccount(pinnedAvailable, existing.accountId))
211
+ sessions.set(sessionId, {
212
+ accountId: pinnedAvailable.apiKeyEnv || pinnedAvailable.id,
213
+ assignedAt: existing ? existing.assignedAt : now,
214
+ lastUsedAt: now
215
+ })
216
+ return {
217
+ account: pinnedAvailable,
218
+ isReassigned: reassigned,
219
+ reason: reassigned ? 'pinned_override' : (existing ? 'sticky_pinned' : 'pinned_active')
220
+ }
221
+ }
222
+
223
+ // If in auto mode or pinned account is unavailable:
224
+ if (existing) {
225
+ const assigned = safeAccounts.find(a => matchesAccount(a, existing.accountId))
226
+ if (assigned && !isAccountBlocked(assigned)) {
227
+ existing.lastUsedAt = now
228
+ return { account: assigned, isReassigned: false, reason: 'sticky_pinned' }
229
+ }
230
+ // Otherwise, failover is needed
231
+ }
232
+
233
+ // Prune if needed
176
234
  if (!existing && sessions.size >= MAX_SESSIONS) {
177
235
  pruneSessions(now, MAX_SESSIONS - 1, SESSION_TTL_MS)
178
236
  }
179
237
 
180
- // New session or mid-session failover: pick least-used account
181
- const chosen = selectLeastUsedAccount(safeAccounts) || safeAccounts.find(a => a.id === defaultActiveId) || safeAccounts[0]
238
+ // Pick least-used available account
239
+ const chosen = selectLeastUsedAccount(safeAccounts) || safeAccounts[0]
182
240
  if (chosen) {
183
- const chosenId = chosen.id || chosen.label
184
241
  sessions.set(sessionId, {
185
- accountId: chosenId,
242
+ accountId: chosen.apiKeyEnv || chosen.id,
186
243
  assignedAt: existing ? existing.assignedAt : now,
187
244
  lastUsedAt: now
188
245
  })
@@ -191,7 +248,7 @@ export function resolveSessionAccount(sessionId, accounts = [], defaultActiveId
191
248
  return {
192
249
  account: chosen,
193
250
  isReassigned: Boolean(existing),
194
- reason: existing ? 'mid_session_failover' : 'new_session_assigned'
251
+ reason: existing ? 'mid_session_failover' : (pinnedAccount ? 'pinned_in_cooldown_fallback' : 'new_session_assigned')
195
252
  }
196
253
  }
197
254
 
@@ -16,6 +16,7 @@ import {
16
16
  clearUsageCache,
17
17
  clearProbeCache,
18
18
  usageCache,
19
+ buildUsageCacheKey,
19
20
  sessionStats,
20
21
  } from './cline-client.js'
21
22
 
@@ -33,7 +34,7 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
33
34
 
34
35
  // 1. Subcommand /cline models
35
36
  if (subcmd === 'models') {
36
- const allModels = getAllModels(pub.dynamicModels, pub.modelContextOverrides)
37
+ const allModels = getAllModels(pub.dynamicModels, pub.modelContextOverrides, pub.customModels)
37
38
  const disabledSet = new Set(pub.disabledModels || [])
38
39
  const activeCount = allModels.filter((m) => !disabledSet.has(m.id)).length
39
40
  const syncStatus = pub.planSynced
@@ -76,7 +77,7 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
76
77
  for (const acc of pool) {
77
78
  const pinStr = acc.isPinned ? ' [Pinned]' : ''
78
79
  const statusStr = acc.present ? 'Configured' : 'Missing Key'
79
- const cacheKey = `cline:usage:${(acc.value || '').slice(-8)}`
80
+ const cacheKey = buildUsageCacheKey(pub.baseUrl, acc.value)
80
81
  const cached = usageCache.get(cacheKey)?.data
81
82
  const usageInfo = cached?.windows?.fiveHour ? ` · 5h ${cached.windows.fiveHour.percentUsed}% used` : ''
82
83
  lines.push(` ${acc.label} (${acc.apiKeyEnv})`)
@@ -156,7 +157,7 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
156
157
 
157
158
  // 7. Subcommand /cline test [model] / smoke
158
159
  if (subcmd === 'test' || subcmd === 'smoke') {
159
- if (param && !isSupportedModel(param, pub.dynamicModels)) {
160
+ if (param && !isSupportedModel(param, pub.dynamicModels, pub.modelContextOverrides, pub.customModels)) {
160
161
  return `ClineBot: Model "${param}" is not recognized. Use /cline models to see available models.`
161
162
  }
162
163
  const activeKey = await resolveActiveAccountKey(ctx, live())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
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",