@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 +22 -0
- package/lib/account-pool.js +47 -4
- package/lib/client.js +21 -10
- package/lib/cline-client.js +58 -8
- package/lib/config.js +4 -0
- package/lib/index.js +43 -32
- package/lib/models.js +35 -9
- package/lib/provider-sync.js +10 -10
- package/lib/routes/models.js +4 -4
- package/lib/routes/proxy.js +190 -49
- package/lib/session-router.js +83 -26
- package/lib/slash-command.js +4 -3
- package/package.json +1 -1
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
|
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,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:
|
|
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
|
|
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 =
|
|
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) =>
|
|
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,
|
|
@@ -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
|
-
|
|
90
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
505
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -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
|
|
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
|
-
|
|
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)
|
|
308
|
+
if (usageData?.ok && Array.isArray(usageData.dynamicModels)) {
|
|
308
309
|
const existingDynamic = pub.dynamicModels || []
|
|
309
|
-
const
|
|
310
|
-
const hasNew = usageData.dynamicModels.some((m) => !existingIds.has(m.id))
|
|
310
|
+
const isDifferent = isCatalogDifferent(existingDynamic, usageData.dynamicModels)
|
|
311
311
|
|
|
312
|
-
if (
|
|
312
|
+
if (isDifferent && settingsApi?.replace) {
|
|
313
313
|
const now = Date.now()
|
|
314
314
|
const next = plainConfig(Config({
|
|
315
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
|
@@ -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
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
|
|
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
|
-
|
|
181
|
-
|
|
213
|
+
clearTimeout(connectTimer)
|
|
214
|
+
if (clientAbortCtrl.signal.aborted || clientClosed) {
|
|
215
|
+
cleanupClientListeners()
|
|
182
216
|
return
|
|
183
217
|
}
|
|
184
218
|
lastError = fetchErr
|
|
185
|
-
const isTimeout =
|
|
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
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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:
|
|
333
|
-
type: '
|
|
334
|
-
code: '
|
|
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
|
})
|
package/lib/session-router.js
CHANGED
|
@@ -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 !
|
|
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).
|
|
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 =
|
|
130
|
-
const bUsed =
|
|
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]
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
154
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
181
|
-
const chosen = selectLeastUsedAccount(safeAccounts) || safeAccounts
|
|
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:
|
|
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
|
|
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",
|