@goodandready/dsh-clinebot 0.5.7 → 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,19 @@ 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
+
8
21
  ## [0.5.7] - 2026-09-30
9
22
 
10
23
  ### Fixed
@@ -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,9 +32,9 @@ function toPlainSnapshot(cfg) {
32
32
  return out
33
33
  }
34
34
 
35
- export function getAccountQuotaSnapshot(accValue) {
35
+ export function getAccountQuotaSnapshot(accValue, baseUrl = DEFAULT_BASE_URL) {
36
36
  if (!accValue || typeof accValue !== 'string') return null
37
- const cacheKey = `cline:usage:${accValue.slice(-8)}`
37
+ const cacheKey = buildUsageCacheKey(baseUrl, accValue)
38
38
  const cached = usageCache.get(cacheKey)?.data
39
39
  if (!cached?.windows) return null
40
40
 
@@ -67,6 +67,7 @@ export function getAccountQuotaSnapshot(accValue) {
67
67
  }
68
68
 
69
69
  export async function resolveAccountPool(ctx, cfg) {
70
+ const baseUrl = textSetting(cfg?.baseUrl) || DEFAULT_BASE_URL
70
71
  const apiKeyEnv = textSetting(cfg?.apiKeyEnv) || DEFAULT_API_KEY_ENV
71
72
  const defaultSlot = {
72
73
  id: 'default',
@@ -86,7 +87,7 @@ export async function resolveAccountPool(ctx, cfg) {
86
87
  const isPinned = activeAccount === 'auto'
87
88
  ? false
88
89
  : (activeAccount ? (activeAccount === envName || activeAccount === slotId) : i === 0)
89
- const quota = keyInfo.value ? getAccountQuotaSnapshot(keyInfo.value) : null
90
+ const quota = keyInfo.value ? getAccountQuotaSnapshot(keyInfo.value, baseUrl) : null
90
91
  resolved.push({
91
92
  id: slotId,
92
93
  label: textSetting(slot.label) || (i === 0 ? 'Default' : `Account ${i + 1}`),
@@ -128,6 +129,7 @@ export function isAccountQuotaExhausted(usage) {
128
129
  * Prioritizes accounts with lowest percentUsed and respects resetsAt recovery.
129
130
  */
130
131
  export async function rotateToNextAccount(ctx, cfg, reason = 'rate_limit', settingsApi = null) {
132
+ const baseUrl = textSetting(cfg?.baseUrl) || DEFAULT_BASE_URL
131
133
  const pool = await resolveAccountPool(ctx, cfg)
132
134
  const configured = pool.filter((acc) => acc.present && acc.value)
133
135
  if (configured.length <= 1) {
@@ -148,7 +150,7 @@ export async function rotateToNextAccount(ctx, cfg, reason = 'rate_limit', setti
148
150
  let lowestUsagePct = Infinity
149
151
 
150
152
  for (const cand of candidates) {
151
- const cacheKey = `cline:usage:${cand.value.slice(-8)}`
153
+ const cacheKey = buildUsageCacheKey(baseUrl, cand.value)
152
154
  const cached = usageCache.get(cacheKey)?.data
153
155
  const isExhausted = isAccountQuotaExhausted(cached)
154
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,
@@ -87,8 +88,32 @@ export function clearProbeCache() {
87
88
  probeCache.clear()
88
89
  }
89
90
 
90
- export function clearUsageCache() {
91
- 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
+ }
92
117
  }
93
118
 
94
119
  /**
@@ -106,6 +131,17 @@ export async function retryWithBackoff(fn, {
106
131
  const res = await fn()
107
132
  if (res && typeof res.status === 'number' && [429, 502, 503, 504].includes(res.status) && attempt < maxRetries) {
108
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
+ }
109
145
  const retryAfter = res.headers?.get ? Number(res.headers.get('retry-after')) * 1000 : 0
110
146
  const waitTime = retryAfter > 0 ? Math.min(retryAfter, maxDelayMs) : delay
111
147
  await new Promise((r) => setTimeout(r, waitTime))
@@ -140,7 +176,7 @@ export async function fetchUsageLimits(baseUrl, apiKey, {
140
176
  return { ok: false, error: 'API key is missing' }
141
177
  }
142
178
 
143
- const cacheKey = `cline:usage:${apiKey.slice(-8)}`
179
+ const cacheKey = buildUsageCacheKey(base, apiKey)
144
180
  const now = Date.now()
145
181
 
146
182
  const doFetch = async () => {
@@ -450,15 +486,20 @@ export function buildPiAiProvider({
450
486
  baseUrl = DEFAULT_BASE_URL,
451
487
  apiKeyEnv = DEFAULT_API_KEY_ENV,
452
488
  apiKey,
489
+ dynamicModels = [],
490
+ modelContextOverrides = [],
453
491
  models = [],
454
492
  customModels = [],
455
493
  displayName = PROVIDER_DISPLAY_NAME,
456
494
  } = {}) {
457
- 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]))
458
497
  const modelList = (Array.isArray(models) && models.length ? models : allAvailable).map((m) => {
459
498
  let item
460
499
  if (typeof m === 'string') {
461
- 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 })
462
503
  } else {
463
504
  item = m
464
505
  }
@@ -507,7 +548,6 @@ export function buildPiAiProvider({
507
548
  api: 'openai-completions',
508
549
  baseURL: normalizeBaseUrl(baseUrl),
509
550
  apiKeyEnv: apiKeyEnv || DEFAULT_API_KEY_ENV,
510
- ...(apiKey ? { apiKey } : {}),
511
551
  compat: {
512
552
  supportsReasoningEffort: true,
513
553
  sendSessionAffinityHeaders: true,
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,
@@ -176,6 +178,7 @@ export async function buildStatus(ctx, cfg) {
176
178
  usage: publicUsage(usage),
177
179
  quotaWarning,
178
180
  sessionStats: { ...sessionStats },
181
+ statsSummary: getStatsSummary(),
179
182
  lastRotation: getLastRotation(),
180
183
  isRegistered,
181
184
  availableModels: allModels,
@@ -213,9 +216,10 @@ export async function upsertPiAiProvider(ctx, cfg, activeModelIds) {
213
216
  const providerObj = buildPiAiProvider({
214
217
  baseUrl: effectiveBaseUrl,
215
218
  apiKeyEnv: isProxy ? LOCAL_PROXY_KEY_ENV : (activeAcc?.apiKeyEnv || pub.apiKeyEnv),
216
- apiKey: localToken,
217
219
  models: modelsToRegister.length ? modelsToRegister : allModels,
218
- customModels: [...(pub.dynamicModels || []), ...(pub.customModels || [])],
220
+ dynamicModels: pub.dynamicModels,
221
+ modelContextOverrides: pub.modelContextOverrides,
222
+ customModels: pub.customModels,
219
223
  displayName: PROVIDER_DISPLAY_NAME,
220
224
  })
221
225
 
@@ -301,12 +305,11 @@ export async function autoDiscoverPlanModels(ctx, { live, getSettingsApi, syncPr
301
305
  bypassCache: true,
302
306
  })
303
307
 
304
- if (usageData?.ok && Array.isArray(usageData.dynamicModels) && usageData.dynamicModels.length > 0) {
308
+ if (usageData?.ok && Array.isArray(usageData.dynamicModels)) {
305
309
  const existingDynamic = pub.dynamicModels || []
306
- const existingIds = new Set(existingDynamic.map((m) => m.id))
307
- const hasNew = usageData.dynamicModels.some((m) => !existingIds.has(m.id))
310
+ const isDifferent = isCatalogDifferent(existingDynamic, usageData.dynamicModels)
308
311
 
309
- if (hasNew && settingsApi?.replace) {
312
+ if (isDifferent && settingsApi?.replace) {
310
313
  const now = Date.now()
311
314
  const next = plainConfig(Config({
312
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,
@@ -154,6 +154,8 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
154
154
  let attempt = 0
155
155
  let lastError = null
156
156
  let hadTimeout = false
157
+ let lastTargetAccount = null
158
+ let lastRetryAfter = null
157
159
 
158
160
  while (attempt < maxRetries) {
159
161
  attempt += 1
@@ -165,6 +167,7 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
165
167
 
166
168
  const routeResult = resolveSessionAccount(sessionId, pool, pub.activeAccount)
167
169
  const targetAccount = routeResult.account
170
+ lastTargetAccount = targetAccount
168
171
 
169
172
  if (!targetAccount || !targetAccount.value) {
170
173
  cleanupClientListeners()
@@ -230,13 +233,23 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
230
233
  const accountId = targetAccount.apiKeyEnv || targetAccount.id || targetAccount.label
231
234
  markAccountCooldown(accountId, 60000, null, 'upstream_429')
232
235
  if (targetAccount.id && targetAccount.id !== accountId) markAccountCooldown(targetAccount.id, 60000, null, 'upstream_429')
233
- recordUsage({
234
- model: body.model,
235
- accountId,
236
- is429: true,
237
- isError: true,
238
- statsPath: pub.statsPath
239
- })
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
+ }
240
253
  ctx?.logger?.warn?.(`[dsh-clinebot:proxy] Account "${accountId}" hit 429, attempting failover (attempt ${attempt}/${maxRetries})`)
241
254
  continue
242
255
  }
@@ -419,21 +432,47 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
419
432
 
420
433
  cleanupClientListeners()
421
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
+ })
422
443
  if (hadTimeout) {
423
444
  return writeJson(res, 504, {
424
445
  error: {
425
446
  message: 'Gateway Timeout: upstream provider timed out across all available accounts',
426
447
  type: 'gateway_timeout',
427
- 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
428
464
  }
429
465
  })
430
466
  }
431
467
 
432
- 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, {
433
472
  error: {
434
- message: 'All configured accounts in pool exceeded rate limits or quota',
435
- type: 'rate_limit_exceeded',
436
- 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',
437
476
  details: lastError?.message || null
438
477
  }
439
478
  })
@@ -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.7",
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",