@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 +13 -0
- package/lib/account-pool.js +7 -5
- package/lib/client.js +21 -10
- package/lib/cline-client.js +46 -6
- package/lib/index.js +43 -32
- package/lib/models.js +35 -9
- package/lib/provider-sync.js +9 -6
- package/lib/routes/models.js +4 -4
- package/lib/routes/proxy.js +51 -12
- package/lib/slash-command.js +4 -3
- package/package.json +1 -1
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
|
package/lib/account-pool.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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) =>
|
|
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' }, `${
|
|
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(
|
|
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' }, `${
|
|
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
|
|
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
|
|
1831
|
-
|
|
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: '
|
|
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
|
}
|
package/lib/cline-client.js
CHANGED
|
@@ -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
|
-
|
|
91
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
+
}
|
package/lib/provider-sync.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
308
|
+
if (usageData?.ok && Array.isArray(usageData.dynamicModels)) {
|
|
305
309
|
const existingDynamic = pub.dynamicModels || []
|
|
306
|
-
const
|
|
307
|
-
const hasNew = usageData.dynamicModels.some((m) => !existingIds.has(m.id))
|
|
310
|
+
const isDifferent = isCatalogDifferent(existingDynamic, usageData.dynamicModels)
|
|
308
311
|
|
|
309
|
-
if (
|
|
312
|
+
if (isDifferent && settingsApi?.replace) {
|
|
310
313
|
const now = Date.now()
|
|
311
314
|
const next = plainConfig(Config({
|
|
312
315
|
...plainConfig(live()),
|
package/lib/routes/models.js
CHANGED
|
@@ -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,
|
package/lib/routes/proxy.js
CHANGED
|
@@ -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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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:
|
|
435
|
-
type: '
|
|
436
|
-
code: '
|
|
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
|
})
|
package/lib/slash-command.js
CHANGED
|
@@ -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 =
|
|
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.
|
|
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",
|