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