@goodandready/dsh-clinebot 0.6.1 → 0.6.2
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 +7 -0
- package/README.md +6 -14
- package/README.ru.md +2 -13
- package/README.zh.md +2 -13
- package/lib/client.js +9 -139
- package/lib/cline-client.js +0 -102
- package/lib/config.js +0 -8
- package/lib/index.js +7 -201
- package/lib/provider-sync.js +2 -141
- package/lib/routes/accounts.js +1 -37
- package/lib/transport/cline-transport.js +1 -1
- package/package.json +1 -1
- package/lib/proxy-server.js +0 -138
- package/lib/proxy-token.js +0 -187
- package/lib/routes/proxy.js +0 -578
package/lib/routes/proxy.js
DELETED
|
@@ -1,578 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Transparent Loopback Proxy & Zero-Downtime Failover (Issues #127, #145, #147, #211, #212)
|
|
3
|
-
*
|
|
4
|
-
* Exposes local OpenAI-compatible endpoints:
|
|
5
|
-
* - POST /dsh-clinebot/v1/chat/completions
|
|
6
|
-
* - GET /dsh-clinebot/v1/models
|
|
7
|
-
*
|
|
8
|
-
* Implements:
|
|
9
|
-
* 1. Bearer local proxy token verification (Issue #145).
|
|
10
|
-
* 2. Origin & Sec-Fetch-Site rejection to prevent cross-site/browser CSRF attacks (Issue #145).
|
|
11
|
-
* 3. Dynamic User-Agent extraction from package.json (Issue #147).
|
|
12
|
-
* 4. Configurable upstream timeout with multi-account failover and 504 handling (Issue #147).
|
|
13
|
-
* 5. Sticky session routing with least-used and round-robin quota allocation (Issue #211).
|
|
14
|
-
* 6. Pre-first-token failover: commits SSE headers only after first valid token/chunk (Issue #212).
|
|
15
|
-
* 7. Streaming SSE token usage interception & persistent telemetry recording.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { writeJson, readBody } from '../http.js'
|
|
19
|
-
import { publicConfig, plainConfig } from '../config.js'
|
|
20
|
-
import { resolveSessionAccount, markAccountCooldown } from '../session-router.js'
|
|
21
|
-
import { recordUsage } from '../stats-storage.js'
|
|
22
|
-
import { getAllModels, DEFAULT_MODEL_ID } from '../models.js'
|
|
23
|
-
import { optimizePromptCacheAndBudget } from '../multimodal-budget.js'
|
|
24
|
-
import {
|
|
25
|
-
getLocalProxyToken, hasDisallowedBrowserOrigin, isAuthorizedProxyRequest,
|
|
26
|
-
assertAuthorizedProxyRequest,
|
|
27
|
-
USER_AGENT, sendProxyTerminalError, extractUsageMetrics
|
|
28
|
-
} from '../proxy-token.js'
|
|
29
|
-
|
|
30
|
-
export { hasDisallowedBrowserOrigin, isAuthorizedProxyRequest, assertAuthorizedProxyRequest, USER_AGENT }
|
|
31
|
-
|
|
32
|
-
export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncProviderState }) {
|
|
33
|
-
return async (req, res) => {
|
|
34
|
-
if (req.method !== 'POST') {
|
|
35
|
-
return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const auth = await assertAuthorizedProxyRequest(ctx, req, res, live)
|
|
39
|
-
if (!auth.ok) return
|
|
40
|
-
const pool = auth.pool
|
|
41
|
-
|
|
42
|
-
let bodyBuf
|
|
43
|
-
try {
|
|
44
|
-
bodyBuf = await readBody(req, 64 * 1024 * 1024)
|
|
45
|
-
} catch (err) {
|
|
46
|
-
return writeJson(res, 400, { ok: false, error: String(err?.message || err) })
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
let body
|
|
50
|
-
try {
|
|
51
|
-
body = JSON.parse(bodyBuf.toString('utf8') || '{}')
|
|
52
|
-
} catch {
|
|
53
|
-
return writeJson(res, 400, { ok: false, error: 'Invalid JSON request body' })
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const cfg = plainConfig(live())
|
|
57
|
-
const pub = publicConfig(cfg)
|
|
58
|
-
|
|
59
|
-
const h = req.headers || {}
|
|
60
|
-
const sessionId = String(
|
|
61
|
-
h['x-dsh-session-id'] || h['x-session-id'] || h['session-id'] || h['session_id'] ||
|
|
62
|
-
h['x-session-affinity'] || h['x-client-request-id'] ||
|
|
63
|
-
body?.session_id || body?.conversation_id || body?.prompt_cache_key || body?.chat_id || body?.user || ''
|
|
64
|
-
).trim()
|
|
65
|
-
|
|
66
|
-
if (sessionId) {
|
|
67
|
-
if (!body.session_id) body.session_id = sessionId
|
|
68
|
-
if (!body.conversation_id) body.conversation_id = sessionId
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
body.model = String(body.model || pub.defaultModel || DEFAULT_MODEL_ID).trim()
|
|
72
|
-
if (pub.modelReasoningDefaults?.[body.model] && !body.reasoning_effort) {
|
|
73
|
-
body.reasoning_effort = pub.modelReasoningDefaults[body.model]
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
if (Array.isArray(body?.messages) && body.messages.length > 0) {
|
|
77
|
-
const budgetResult = optimizePromptCacheAndBudget(body.messages, {
|
|
78
|
-
offloadSeenImages: pub.offloadSeenImages !== false,
|
|
79
|
-
maxPayloadBytes: (Number(pub.maxPayloadMb) || 40) * 1024 * 1024,
|
|
80
|
-
})
|
|
81
|
-
body.messages = budgetResult.messages
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const isStreaming = Boolean(body.stream)
|
|
85
|
-
const configuredAccounts = pool.filter(a => a.present && a.value)
|
|
86
|
-
|
|
87
|
-
const clientAbortCtrl = new AbortController()
|
|
88
|
-
let clientClosed = false
|
|
89
|
-
const onClientClose = () => {
|
|
90
|
-
clientClosed = true
|
|
91
|
-
if (!res.writableFinished) {
|
|
92
|
-
clientAbortCtrl.abort()
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
if (typeof res.on === 'function') res.on('close', onClientClose)
|
|
96
|
-
if (typeof req.on === 'function') req.on('close', onClientClose)
|
|
97
|
-
|
|
98
|
-
const cleanupClientListeners = () => {
|
|
99
|
-
if (typeof res.off === 'function') res.off('close', onClientClose)
|
|
100
|
-
else if (typeof res.removeListener === 'function') res.removeListener('close', onClientClose)
|
|
101
|
-
if (typeof req.off === 'function') req.off('close', onClientClose)
|
|
102
|
-
else if (typeof req.removeListener === 'function') req.removeListener('close', onClientClose)
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const maxRetries = Math.max(1, configuredAccounts.length)
|
|
106
|
-
let attempt = 0
|
|
107
|
-
let lastError = null
|
|
108
|
-
let hadTimeout = false
|
|
109
|
-
let lastTargetAccount = null
|
|
110
|
-
let lastRetryAfter = null
|
|
111
|
-
|
|
112
|
-
while (attempt < maxRetries) {
|
|
113
|
-
attempt += 1
|
|
114
|
-
|
|
115
|
-
if (clientAbortCtrl.signal.aborted || clientClosed) {
|
|
116
|
-
cleanupClientListeners()
|
|
117
|
-
return
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const routeResult = resolveSessionAccount(sessionId, pool, pub.activeAccount, pub.accountMode, body?.model, pub.modelRoutes)
|
|
121
|
-
const targetAccount = routeResult.account
|
|
122
|
-
lastTargetAccount = targetAccount
|
|
123
|
-
|
|
124
|
-
if (!targetAccount || !targetAccount.value) {
|
|
125
|
-
cleanupClientListeners()
|
|
126
|
-
return writeJson(res, 503, {
|
|
127
|
-
error: {
|
|
128
|
-
message: 'No available ClinePass API keys configured in pool',
|
|
129
|
-
type: 'service_unavailable',
|
|
130
|
-
code: 'no_keys_available'
|
|
131
|
-
}
|
|
132
|
-
})
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const accountId = targetAccount.apiKeyEnv || targetAccount.id || targetAccount.label
|
|
136
|
-
const upstreamUrl = `${pub.baseUrl}/chat/completions`
|
|
137
|
-
const requestHeaders = {
|
|
138
|
-
'Content-Type': 'application/json',
|
|
139
|
-
'Authorization': `Bearer ${targetAccount.value}`,
|
|
140
|
-
'Accept': isStreaming ? 'text/event-stream' : 'application/json',
|
|
141
|
-
'User-Agent': USER_AGENT
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const connectTimeoutMs = Math.max(50, Number(pub.timeoutMs) || 15000)
|
|
145
|
-
const streamIdleTimeoutMs = Math.max(50, Number(pub.streamIdleTimeoutMs) || 30000)
|
|
146
|
-
|
|
147
|
-
const connectAbortCtrl = new AbortController()
|
|
148
|
-
const bodyAbortCtrl = new AbortController()
|
|
149
|
-
let connectTimedOut = false
|
|
150
|
-
const connectTimer = setTimeout(() => {
|
|
151
|
-
connectTimedOut = true
|
|
152
|
-
connectAbortCtrl.abort(new Error(`Upstream connect timeout after ${connectTimeoutMs}ms`))
|
|
153
|
-
}, connectTimeoutMs)
|
|
154
|
-
|
|
155
|
-
const fetchSignal = AbortSignal.any([clientAbortCtrl.signal, connectAbortCtrl.signal, bodyAbortCtrl.signal])
|
|
156
|
-
|
|
157
|
-
let upstreamRes
|
|
158
|
-
try {
|
|
159
|
-
upstreamRes = await fetch(upstreamUrl, {
|
|
160
|
-
method: 'POST',
|
|
161
|
-
headers: requestHeaders,
|
|
162
|
-
body: JSON.stringify(body),
|
|
163
|
-
signal: fetchSignal,
|
|
164
|
-
keepalive: true
|
|
165
|
-
})
|
|
166
|
-
} catch (fetchErr) {
|
|
167
|
-
clearTimeout(connectTimer)
|
|
168
|
-
if (clientAbortCtrl.signal.aborted || clientClosed) {
|
|
169
|
-
cleanupClientListeners()
|
|
170
|
-
return
|
|
171
|
-
}
|
|
172
|
-
lastError = fetchErr
|
|
173
|
-
const isTimeout = connectTimedOut || connectAbortCtrl.signal.aborted || fetchErr.name === 'TimeoutError' || String(fetchErr).includes('timeout')
|
|
174
|
-
if (isTimeout) {
|
|
175
|
-
hadTimeout = true
|
|
176
|
-
markAccountCooldown(accountId, 60000, null, 'upstream_timeout')
|
|
177
|
-
if (targetAccount.id && targetAccount.id !== accountId) markAccountCooldown(targetAccount.id, 60000, null, 'upstream_timeout')
|
|
178
|
-
ctx?.logger?.warn?.(`[dsh-clinebot:proxy] Account "${accountId}" upstream connect timed out after ${connectTimeoutMs}ms, attempting failover (attempt ${attempt}/${maxRetries})`)
|
|
179
|
-
} else {
|
|
180
|
-
markAccountCooldown(accountId, 60000, null, 'upstream_fetch_error')
|
|
181
|
-
if (targetAccount.id && targetAccount.id !== accountId) markAccountCooldown(targetAccount.id, 60000, null, 'upstream_fetch_error')
|
|
182
|
-
ctx?.logger?.warn?.(`[dsh-clinebot:proxy] Account "${accountId}" connect error (${fetchErr.message}), attempting failover (attempt ${attempt}/${maxRetries})`)
|
|
183
|
-
}
|
|
184
|
-
continue
|
|
185
|
-
}
|
|
186
|
-
clearTimeout(connectTimer)
|
|
187
|
-
|
|
188
|
-
if (clientAbortCtrl.signal.aborted || clientClosed) {
|
|
189
|
-
cleanupClientListeners()
|
|
190
|
-
return
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// Pre-first-token failover: check for retryable non-200 responses before sending ANY headers
|
|
194
|
-
if (upstreamRes.status !== 200) {
|
|
195
|
-
const isRateLimit = upstreamRes.status === 429 || upstreamRes.status === 402
|
|
196
|
-
const isServerErr = upstreamRes.status >= 500 && upstreamRes.status <= 599
|
|
197
|
-
const isAuthErr = upstreamRes.status === 401 || upstreamRes.status === 403
|
|
198
|
-
|
|
199
|
-
if (isRateLimit || isServerErr || isAuthErr) {
|
|
200
|
-
const reason = isRateLimit ? 'upstream_429' : (isAuthErr ? 'upstream_auth' : `upstream_status_${upstreamRes.status}`)
|
|
201
|
-
markAccountCooldown(accountId, 60000, null, reason)
|
|
202
|
-
if (targetAccount.id && targetAccount.id !== accountId) markAccountCooldown(targetAccount.id, 60000, null, reason)
|
|
203
|
-
lastError = new Error(`Upstream rejected with HTTP ${upstreamRes.status}`)
|
|
204
|
-
lastError.status = upstreamRes.status
|
|
205
|
-
const retryAfter = upstreamRes.headers?.get?.('retry-after')
|
|
206
|
-
if (retryAfter) lastRetryAfter = retryAfter
|
|
207
|
-
if (upstreamRes.body) {
|
|
208
|
-
try {
|
|
209
|
-
if (typeof upstreamRes.body.cancel === 'function') upstreamRes.body.cancel().catch(() => {})
|
|
210
|
-
else if (typeof upstreamRes.body.destroy === 'function') upstreamRes.body.destroy()
|
|
211
|
-
} catch {}
|
|
212
|
-
}
|
|
213
|
-
ctx?.logger?.warn?.(`[dsh-clinebot:proxy] Account "${accountId}" returned HTTP ${upstreamRes.status}, attempting failover (attempt ${attempt}/${maxRetries})`)
|
|
214
|
-
continue
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// Non-retryable client error (e.g. 400 Bad Request)
|
|
218
|
-
cleanupClientListeners()
|
|
219
|
-
const errText = await upstreamRes.text().catch(() => '')
|
|
220
|
-
res.statusCode = upstreamRes.status
|
|
221
|
-
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
222
|
-
res.end(errText || JSON.stringify({ error: { message: `Upstream error ${upstreamRes.status}` } }))
|
|
223
|
-
return
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// Streaming response with pre-first-token buffering (#212)
|
|
227
|
-
if (isStreaming && upstreamRes.status === 200 && upstreamRes.body) {
|
|
228
|
-
const reader = upstreamRes.body.getReader()
|
|
229
|
-
const decoder = new TextDecoder('utf8')
|
|
230
|
-
|
|
231
|
-
let firstReadResult = null
|
|
232
|
-
let preTokenError = null
|
|
233
|
-
|
|
234
|
-
const ttftAbortCtrl = new AbortController()
|
|
235
|
-
const ttftTimer = setTimeout(() => {
|
|
236
|
-
ttftAbortCtrl.abort(new Error(`TTFT timeout waiting for first stream chunk after ${connectTimeoutMs}ms`))
|
|
237
|
-
}, connectTimeoutMs)
|
|
238
|
-
|
|
239
|
-
try {
|
|
240
|
-
const firstReadPromise = reader.read()
|
|
241
|
-
const timeoutPromise = new Promise((_, reject) => {
|
|
242
|
-
ttftAbortCtrl.signal.addEventListener('abort', () => reject(ttftAbortCtrl.signal.reason), { once: true })
|
|
243
|
-
})
|
|
244
|
-
firstReadResult = await Promise.race([firstReadPromise, timeoutPromise])
|
|
245
|
-
} catch (err) {
|
|
246
|
-
preTokenError = err
|
|
247
|
-
} finally {
|
|
248
|
-
clearTimeout(ttftTimer)
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
if (clientAbortCtrl.signal.aborted || clientClosed) {
|
|
252
|
-
cleanupClientListeners()
|
|
253
|
-
try { reader.cancel('client aborted').catch(() => {}) } catch {}
|
|
254
|
-
return
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
let earlyError = null
|
|
258
|
-
if (!preTokenError && firstReadResult?.value) {
|
|
259
|
-
const textSample = decoder.decode(firstReadResult.value, { stream: true })
|
|
260
|
-
const trimmed = textSample.trim()
|
|
261
|
-
if (trimmed.startsWith('{') && trimmed.includes('"error"')) {
|
|
262
|
-
try {
|
|
263
|
-
const parsed = JSON.parse(trimmed)
|
|
264
|
-
if (parsed.error && !parsed.choices) earlyError = new Error(parsed.error.message || 'Upstream error')
|
|
265
|
-
} catch {}
|
|
266
|
-
} else if (trimmed.startsWith('data:') && trimmed.includes('"error"')) {
|
|
267
|
-
try {
|
|
268
|
-
const parsed = JSON.parse(trimmed.slice(5).trim())
|
|
269
|
-
if (parsed.error && !parsed.choices) earlyError = new Error(parsed.error.message || 'Upstream stream error')
|
|
270
|
-
} catch {}
|
|
271
|
-
}
|
|
272
|
-
} else if (!preTokenError && firstReadResult?.done) {
|
|
273
|
-
earlyError = new Error('Upstream stream completed with zero chunks')
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
if (preTokenError || earlyError) {
|
|
277
|
-
if (ttftAbortCtrl.signal.aborted || String(preTokenError?.message).includes('timeout')) hadTimeout = true
|
|
278
|
-
try { reader.cancel('pre-first-token error').catch(() => {}) } catch {}
|
|
279
|
-
markAccountCooldown(accountId, 60000, null, 'upstream_pre_token_error')
|
|
280
|
-
if (targetAccount.id && targetAccount.id !== accountId) markAccountCooldown(targetAccount.id, 60000, null, 'upstream_pre_token_error')
|
|
281
|
-
lastError = preTokenError || earlyError
|
|
282
|
-
ctx?.logger?.warn?.(`[dsh-clinebot:proxy] Account "${accountId}" failed before first token (${lastError.message}), attempting failover (attempt ${attempt}/${maxRetries})`)
|
|
283
|
-
continue
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// First token received: commit headers to client now
|
|
287
|
-
res.statusCode = 200
|
|
288
|
-
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
|
|
289
|
-
res.setHeader('Cache-Control', 'no-cache, no-transform')
|
|
290
|
-
res.setHeader('Connection', 'keep-alive')
|
|
291
|
-
if (typeof res.flushHeaders === 'function') {
|
|
292
|
-
res.flushHeaders()
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
let promptTokens = 0
|
|
296
|
-
let completionTokens = 0
|
|
297
|
-
let reasoningTokens = 0
|
|
298
|
-
let totalTokens = 0
|
|
299
|
-
let buffer = ''
|
|
300
|
-
let streamError = null
|
|
301
|
-
let streamCompletedCleanly = false
|
|
302
|
-
|
|
303
|
-
let streamAborted = false
|
|
304
|
-
const onStreamAbort = () => {
|
|
305
|
-
streamAborted = true
|
|
306
|
-
try {
|
|
307
|
-
reader.cancel('client closed response').catch(() => {})
|
|
308
|
-
} catch {}
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
if (clientAbortCtrl.signal.aborted || clientClosed) {
|
|
312
|
-
onStreamAbort()
|
|
313
|
-
} else {
|
|
314
|
-
clientAbortCtrl.signal.addEventListener('abort', onStreamAbort, { once: true })
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
let idleTimer = null
|
|
318
|
-
let idleTimedOut = false
|
|
319
|
-
const resetIdleTimer = () => {
|
|
320
|
-
if (idleTimer) clearTimeout(idleTimer)
|
|
321
|
-
idleTimer = setTimeout(() => {
|
|
322
|
-
idleTimedOut = true
|
|
323
|
-
streamError = new Error(`Stream idle timeout after ${streamIdleTimeoutMs}ms`)
|
|
324
|
-
try {
|
|
325
|
-
reader.cancel('stream idle timeout').catch(() => {})
|
|
326
|
-
} catch {}
|
|
327
|
-
}, streamIdleTimeoutMs)
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
const clearIdleTimer = () => {
|
|
331
|
-
if (idleTimer) {
|
|
332
|
-
clearTimeout(idleTimer)
|
|
333
|
-
idleTimer = null
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
const processChunk = (val) => {
|
|
338
|
-
if (!res.destroyed && !res.writableEnded) {
|
|
339
|
-
res.write(val)
|
|
340
|
-
}
|
|
341
|
-
const chunkStr = decoder.decode(val, { stream: true })
|
|
342
|
-
buffer += chunkStr
|
|
343
|
-
const lines = buffer.split('\n')
|
|
344
|
-
buffer = lines.pop() || ''
|
|
345
|
-
for (const line of lines) {
|
|
346
|
-
const trimmed = line.trim()
|
|
347
|
-
if (trimmed.startsWith('data:') && trimmed !== 'data: [DONE]') {
|
|
348
|
-
try {
|
|
349
|
-
const parsed = JSON.parse(trimmed.slice(5).trim())
|
|
350
|
-
if (parsed.usage) {
|
|
351
|
-
const u = extractUsageMetrics(parsed.usage)
|
|
352
|
-
promptTokens = u.promptTokens || promptTokens
|
|
353
|
-
completionTokens = u.completionTokens || completionTokens
|
|
354
|
-
reasoningTokens = u.reasoningTokens || reasoningTokens
|
|
355
|
-
totalTokens = u.totalTokens || totalTokens
|
|
356
|
-
}
|
|
357
|
-
} catch {}
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
if (firstReadResult?.value) {
|
|
363
|
-
processChunk(firstReadResult.value)
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
if (firstReadResult?.done) {
|
|
367
|
-
streamCompletedCleanly = true
|
|
368
|
-
} else {
|
|
369
|
-
try {
|
|
370
|
-
resetIdleTimer()
|
|
371
|
-
while (true) {
|
|
372
|
-
if (streamAborted || clientAbortCtrl.signal.aborted || clientClosed) break
|
|
373
|
-
const { done, value } = await reader.read()
|
|
374
|
-
if (idleTimedOut || streamError) break
|
|
375
|
-
if (done || streamAborted) {
|
|
376
|
-
if (done && !streamAborted && !clientClosed && !idleTimedOut && !streamError) {
|
|
377
|
-
streamCompletedCleanly = true
|
|
378
|
-
}
|
|
379
|
-
break
|
|
380
|
-
}
|
|
381
|
-
resetIdleTimer()
|
|
382
|
-
processChunk(value)
|
|
383
|
-
if (streamAborted || clientAbortCtrl.signal.aborted || clientClosed) break
|
|
384
|
-
}
|
|
385
|
-
} catch (err) {
|
|
386
|
-
streamError = err
|
|
387
|
-
} finally {
|
|
388
|
-
clearIdleTimer()
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
clearIdleTimer()
|
|
393
|
-
clientAbortCtrl.signal.removeEventListener('abort', onStreamAbort)
|
|
394
|
-
cleanupClientListeners()
|
|
395
|
-
try {
|
|
396
|
-
reader.releaseLock()
|
|
397
|
-
} catch {}
|
|
398
|
-
|
|
399
|
-
const isStreamFailed = Boolean(streamError || idleTimedOut || clientClosed || clientAbortCtrl.signal.aborted || !streamCompletedCleanly)
|
|
400
|
-
|
|
401
|
-
if (!res.destroyed && !res.writableEnded) {
|
|
402
|
-
if (isStreamFailed && (idleTimedOut || streamError)) {
|
|
403
|
-
try {
|
|
404
|
-
const errPayload = JSON.stringify({
|
|
405
|
-
error: {
|
|
406
|
-
message: streamError?.message || 'Stream interrupted or timed out',
|
|
407
|
-
type: 'stream_error',
|
|
408
|
-
code: idleTimedOut ? 'stream_idle_timeout' : 'stream_error'
|
|
409
|
-
}
|
|
410
|
-
})
|
|
411
|
-
res.write(`data: ${errPayload}\n\n`)
|
|
412
|
-
} catch {}
|
|
413
|
-
}
|
|
414
|
-
res.end()
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
recordUsage({
|
|
418
|
-
model: body.model,
|
|
419
|
-
accountId: targetAccount.id || targetAccount.label,
|
|
420
|
-
promptTokens,
|
|
421
|
-
completionTokens,
|
|
422
|
-
reasoningTokens,
|
|
423
|
-
totalTokens: totalTokens || (promptTokens + completionTokens),
|
|
424
|
-
isError: isStreamFailed,
|
|
425
|
-
statsPath: pub.statsPath
|
|
426
|
-
})
|
|
427
|
-
return
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
// Non-streaming JSON response with deadline and failover (#185, #198, #202, #212)
|
|
431
|
-
const bodyTimeoutMs = Math.max(50, Number(pub.timeoutMs) || 15000)
|
|
432
|
-
let bodyTimedOut = false
|
|
433
|
-
let bodyTimer = null
|
|
434
|
-
const bodyTimeoutPromise = new Promise((_, reject) => {
|
|
435
|
-
bodyTimer = setTimeout(() => {
|
|
436
|
-
bodyTimedOut = true
|
|
437
|
-
bodyAbortCtrl.abort(new Error(`Upstream body read timed out after ${bodyTimeoutMs}ms`))
|
|
438
|
-
reject(new Error(`Upstream body read timed out after ${bodyTimeoutMs}ms`))
|
|
439
|
-
}, bodyTimeoutMs)
|
|
440
|
-
})
|
|
441
|
-
|
|
442
|
-
const MAX_BODY_BYTES = 10 * 1024 * 1024
|
|
443
|
-
const contentLength = Number(upstreamRes.headers.get('content-length'))
|
|
444
|
-
if (contentLength > MAX_BODY_BYTES) {
|
|
445
|
-
clearTimeout(bodyTimer)
|
|
446
|
-
cleanupClientListeners()
|
|
447
|
-
writeJson(res, 502, { ok: false, error: 'Upstream response exceeded maximum allowed size' })
|
|
448
|
-
return
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
let resText = ''
|
|
452
|
-
let readError = null
|
|
453
|
-
try {
|
|
454
|
-
resText = await Promise.race([
|
|
455
|
-
upstreamRes.text(),
|
|
456
|
-
bodyTimeoutPromise,
|
|
457
|
-
])
|
|
458
|
-
if (resText.length > MAX_BODY_BYTES) {
|
|
459
|
-
throw new Error('Upstream response exceeded maximum allowed size')
|
|
460
|
-
}
|
|
461
|
-
} catch (err) {
|
|
462
|
-
readError = err
|
|
463
|
-
} finally {
|
|
464
|
-
clearTimeout(bodyTimer)
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
if (clientAbortCtrl.signal.aborted || clientClosed) {
|
|
468
|
-
cleanupClientListeners()
|
|
469
|
-
return
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
if (bodyTimedOut || readError) {
|
|
473
|
-
const isTimeout = bodyTimedOut || bodyAbortCtrl.signal.aborted
|
|
474
|
-
if (isTimeout) hadTimeout = true
|
|
475
|
-
markAccountCooldown(accountId, 60000, null, isTimeout ? 'upstream_timeout' : 'upstream_read_error')
|
|
476
|
-
if (targetAccount.id && targetAccount.id !== accountId) markAccountCooldown(targetAccount.id, 60000, null, isTimeout ? 'upstream_timeout' : 'upstream_read_error')
|
|
477
|
-
lastError = readError || new Error(`Upstream body read timed out after ${bodyTimeoutMs}ms`)
|
|
478
|
-
lastError.code = isTimeout ? 'body_read_timeout' : 'body_read_error'
|
|
479
|
-
ctx?.logger?.warn?.(`[dsh-clinebot:proxy] Account "${accountId}" body read failed (${lastError.message}), attempting failover (attempt ${attempt}/${maxRetries})`)
|
|
480
|
-
continue
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
let resJson = null
|
|
484
|
-
try {
|
|
485
|
-
resJson = JSON.parse(resText)
|
|
486
|
-
} catch {
|
|
487
|
-
resJson = null
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
if (resJson?.error && !resJson?.choices) {
|
|
491
|
-
markAccountCooldown(accountId, 60000, null, 'upstream_error_payload')
|
|
492
|
-
if (targetAccount.id && targetAccount.id !== accountId) markAccountCooldown(targetAccount.id, 60000, null, 'upstream_error_payload')
|
|
493
|
-
lastError = new Error(resJson.error.message || 'Upstream error response')
|
|
494
|
-
ctx?.logger?.warn?.(`[dsh-clinebot:proxy] Account "${accountId}" returned 200 with error (${lastError.message}), attempting failover (attempt ${attempt}/${maxRetries})`)
|
|
495
|
-
continue
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
cleanupClientListeners()
|
|
499
|
-
const nonStreamUsage = extractUsageMetrics(resJson?.usage)
|
|
500
|
-
recordUsage({
|
|
501
|
-
model: body.model,
|
|
502
|
-
accountId: targetAccount.id || targetAccount.label,
|
|
503
|
-
promptTokens: nonStreamUsage.promptTokens,
|
|
504
|
-
completionTokens: nonStreamUsage.completionTokens,
|
|
505
|
-
reasoningTokens: nonStreamUsage.reasoningTokens,
|
|
506
|
-
totalTokens: nonStreamUsage.totalTokens,
|
|
507
|
-
isError: false,
|
|
508
|
-
statsPath: pub.statsPath
|
|
509
|
-
})
|
|
510
|
-
|
|
511
|
-
res.statusCode = 200
|
|
512
|
-
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
513
|
-
res.end(resText)
|
|
514
|
-
return
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
cleanupClientListeners()
|
|
518
|
-
// Exhausted all retries
|
|
519
|
-
const is429Exhausted = !hadTimeout && (lastError?.status === 429 || lastError?.status === 402)
|
|
520
|
-
recordUsage({
|
|
521
|
-
model: body.model,
|
|
522
|
-
accountId: lastTargetAccount?.id || lastTargetAccount?.label || 'default',
|
|
523
|
-
isError: true,
|
|
524
|
-
is429: is429Exhausted,
|
|
525
|
-
statsPath: pub.statsPath
|
|
526
|
-
})
|
|
527
|
-
return sendProxyTerminalError(res, { hadTimeout, is429Exhausted, lastError, lastRetryAfter })
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
export function createModelsHandler(ctx, { live }) {
|
|
532
|
-
return async (req, res) => {
|
|
533
|
-
if (req.method !== 'GET') {
|
|
534
|
-
return writeJson(res, 405, { ok: false, error: 'GET only' })
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
const auth = await assertAuthorizedProxyRequest(ctx, req, res, live)
|
|
538
|
-
if (!auth.ok) return
|
|
539
|
-
|
|
540
|
-
const pub = publicConfig(live())
|
|
541
|
-
const dynamic = Array.isArray(pub.dynamicModels) ? pub.dynamicModels : []
|
|
542
|
-
const custom = Array.isArray(pub.customModels) ? pub.customModels : []
|
|
543
|
-
const overrides = Array.isArray(pub.modelContextOverrides) ? pub.modelContextOverrides : []
|
|
544
|
-
const allModels = getAllModels(dynamic, overrides, custom)
|
|
545
|
-
|
|
546
|
-
const modelsData = allModels.map(m => ({
|
|
547
|
-
id: m.id,
|
|
548
|
-
object: 'model',
|
|
549
|
-
created: 1700000000,
|
|
550
|
-
owned_by: 'clinebot',
|
|
551
|
-
permission: [],
|
|
552
|
-
root: m.id,
|
|
553
|
-
parent: null
|
|
554
|
-
}))
|
|
555
|
-
|
|
556
|
-
writeJson(res, 200, {
|
|
557
|
-
object: 'list',
|
|
558
|
-
data: modelsData
|
|
559
|
-
})
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
export function registerProxyRoutes(ctx, routeEnv) {
|
|
564
|
-
const chatHandler = createChatCompletionsHandler(ctx, routeEnv)
|
|
565
|
-
const modelsHandler = createModelsHandler(ctx, routeEnv)
|
|
566
|
-
|
|
567
|
-
ctx.effect(() => ctx.webServer.register({
|
|
568
|
-
kind: 'exact',
|
|
569
|
-
path: '/dsh-clinebot/v1/chat/completions',
|
|
570
|
-
handler: chatHandler,
|
|
571
|
-
}), 'dsh-clinebot: /v1/chat/completions')
|
|
572
|
-
|
|
573
|
-
ctx.effect(() => ctx.webServer.register({
|
|
574
|
-
kind: 'exact',
|
|
575
|
-
path: '/dsh-clinebot/v1/models',
|
|
576
|
-
handler: modelsHandler,
|
|
577
|
-
}), 'dsh-clinebot: /v1/models')
|
|
578
|
-
}
|