@goodandready/dsh-clinebot 0.5.32 → 0.6.0
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 +31 -0
- package/README.md +63 -36
- package/README.ru.md +3 -1
- package/README.zh.md +3 -1
- package/lib/adapter-messages.js +237 -0
- package/lib/adapter-stream.js +292 -0
- package/lib/adapter.js +616 -0
- package/lib/client.js +2564 -924
- package/lib/cline-client.js +15 -6
- package/lib/config.js +266 -233
- package/lib/credential-refs.js +104 -84
- package/lib/index.js +569 -502
- package/lib/model-prices.js +117 -0
- package/lib/provider-sync.js +404 -387
- package/lib/proxy-token.js +187 -140
- package/lib/routes/accounts.js +523 -403
- package/lib/routes/models.js +2 -1
- package/lib/routes/proxy.js +8 -27
- package/lib/routes/settings.js +63 -2
- package/lib/services.js +55 -0
- package/lib/stats-storage.js +364 -166
- package/lib/transport/cline-transport.js +314 -0
- package/lib/tui-settings.js +158 -0
- package/lib/updater.js +5 -1
- package/package.json +113 -77
package/lib/adapter.js
ADDED
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClineBot provider adapter for DSH's `ctx.llm` seam.
|
|
3
|
+
*
|
|
4
|
+
* The adapter replaces the previous `llm-pi-ai` + loopback-proxy transport for
|
|
5
|
+
* DSH chat. It owns the OpenAI-compatible wire call, multi-account rotation,
|
|
6
|
+
* pre-first-token failover, usage accounting, and model metadata.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { LlmAdapter, LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
|
10
|
+
import { publicConfig } from './config.js'
|
|
11
|
+
import { getAllModels, findModel, formatModelDescription } from './models.js'
|
|
12
|
+
import { resolveAccountPool } from './account-pool.js'
|
|
13
|
+
import { resolveSessionAccount, markAccountCooldown } from './session-router.js'
|
|
14
|
+
import { recordSessionRequest } from './telemetry.js'
|
|
15
|
+
import { recordUsage } from './stats-storage.js'
|
|
16
|
+
import { estimateCost } from './model-prices.js'
|
|
17
|
+
import { buildOpenAiPayload } from './adapter-messages.js'
|
|
18
|
+
import { streamJsonCompletion, streamOpenAiChunks } from './adapter-stream.js'
|
|
19
|
+
import {
|
|
20
|
+
abortError,
|
|
21
|
+
classifyUpstreamStatus,
|
|
22
|
+
isAbortError,
|
|
23
|
+
isEventStream,
|
|
24
|
+
readResponseText,
|
|
25
|
+
readSseEvents,
|
|
26
|
+
requestChatCompletion,
|
|
27
|
+
} from './transport/cline-transport.js'
|
|
28
|
+
|
|
29
|
+
const PROVIDER = 'clinebot'
|
|
30
|
+
const PROVIDER_NAME = 'ClineBot (ClinePass)'
|
|
31
|
+
const DEFAULT_CONTEXT_WINDOW = 200000
|
|
32
|
+
const DEFAULT_MAX_TOKENS = 8192
|
|
33
|
+
const COOLDOWN_MS = 60000
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Yield one already-consumed item before the rest of an async iterator.
|
|
37
|
+
* @template T
|
|
38
|
+
* @param {T} first
|
|
39
|
+
* @param {AsyncIterator<T> | AsyncIterable<T>} rest
|
|
40
|
+
* @returns {AsyncGenerator<T>}
|
|
41
|
+
*/
|
|
42
|
+
async function* prependAsync(first, rest) {
|
|
43
|
+
yield first
|
|
44
|
+
for await (const item of rest) yield item
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether a chunk carries the first model-visible token.
|
|
49
|
+
* @param {Record<string, unknown>} chunk
|
|
50
|
+
* @returns {boolean}
|
|
51
|
+
*/
|
|
52
|
+
function isContentChunk(chunk) {
|
|
53
|
+
return chunk?.type === 'text-delta'
|
|
54
|
+
|| chunk?.type === 'reasoning-delta'
|
|
55
|
+
|| chunk?.type === 'tool-call-delta'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Read a `Retry-After` header as milliseconds.
|
|
60
|
+
* @param {Headers | undefined} headers
|
|
61
|
+
* @returns {number | undefined}
|
|
62
|
+
*/
|
|
63
|
+
function retryAfterMs(headers) {
|
|
64
|
+
const raw = headers?.get?.('retry-after')
|
|
65
|
+
if (!raw) return undefined
|
|
66
|
+
const seconds = Number(raw)
|
|
67
|
+
return Number.isFinite(seconds) && seconds > 0 ? Math.round(seconds * 1000) : undefined
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Normalize the catalogue's reasoning-effort representation into a clean list.
|
|
72
|
+
* @param {unknown} value
|
|
73
|
+
* @returns {string[]}
|
|
74
|
+
*/
|
|
75
|
+
function reasoningEfforts(value) {
|
|
76
|
+
if (Array.isArray(value)) {
|
|
77
|
+
return value.map((entry) => String(entry || '').trim().toLowerCase()).filter(Boolean)
|
|
78
|
+
}
|
|
79
|
+
if (value && typeof value === 'object') {
|
|
80
|
+
return Object.keys(value).map((entry) => String(entry || '').trim().toLowerCase()).filter(Boolean)
|
|
81
|
+
}
|
|
82
|
+
return []
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Human label for a reasoning effort id.
|
|
87
|
+
* @param {string} id
|
|
88
|
+
* @returns {string}
|
|
89
|
+
*/
|
|
90
|
+
function reasoningLabel(id) {
|
|
91
|
+
if (id === 'low') return 'Low'
|
|
92
|
+
if (id === 'medium') return 'Medium'
|
|
93
|
+
if (id === 'high') return 'High'
|
|
94
|
+
if (id === 'max') return 'Max'
|
|
95
|
+
if (id === 'minimal') return 'Minimal'
|
|
96
|
+
return id
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Read the attachment service defensively.
|
|
101
|
+
* @param {object | undefined} ctx
|
|
102
|
+
* @returns {unknown}
|
|
103
|
+
*/
|
|
104
|
+
function attachmentStore(ctx) {
|
|
105
|
+
try {
|
|
106
|
+
return ctx?.get?.('attachments') || ctx?.attachments
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export class ClineBotAdapter extends LlmAdapter {
|
|
113
|
+
/**
|
|
114
|
+
* @param {{
|
|
115
|
+
* ctx: object,
|
|
116
|
+
* live: () => Record<string, unknown>,
|
|
117
|
+
* resolveAttachments?: () => unknown,
|
|
118
|
+
* logger?: { warn?: Function, info?: Function },
|
|
119
|
+
* }} deps
|
|
120
|
+
*/
|
|
121
|
+
constructor({ ctx, live, resolveAttachments, logger }) {
|
|
122
|
+
super()
|
|
123
|
+
this.ctx = ctx
|
|
124
|
+
this.live = live
|
|
125
|
+
this.resolveAttachments = resolveAttachments
|
|
126
|
+
this.logger = logger
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** @param {string} provider */
|
|
130
|
+
providerInfo(provider) {
|
|
131
|
+
return { id: provider, name: PROVIDER_NAME }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** @param {string} _provider */
|
|
135
|
+
providerRetryPolicy(_provider) {
|
|
136
|
+
return {
|
|
137
|
+
mode: 'normal',
|
|
138
|
+
maxRetries: 1,
|
|
139
|
+
retryableCodes: ['RATE_LIMIT', 'TRANSPORT_ERROR', 'PROVIDER_ERROR'],
|
|
140
|
+
backoff: { initialDelayMs: 500, maxDelayMs: 5000, jitterRatio: 0.1 },
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {string} provider
|
|
146
|
+
* @returns {Promise<Array<Record<string, unknown>>>}
|
|
147
|
+
*/
|
|
148
|
+
async listModels(provider) {
|
|
149
|
+
const pub = publicConfig(this.live())
|
|
150
|
+
const all = getAllModels(pub.dynamicModels, pub.modelContextOverrides, pub.customModels)
|
|
151
|
+
const disabled = new Set(pub.disabledModels || [])
|
|
152
|
+
const visible = new Set(pub.visibleModels || [])
|
|
153
|
+
return all
|
|
154
|
+
.filter((model) => model && model.id)
|
|
155
|
+
.filter((model) => !disabled.has(model.id))
|
|
156
|
+
.filter((model) => visible.size === 0 || visible.has(model.id))
|
|
157
|
+
.filter((model) => !pub.filterModelsByPlan || model.inPlan !== false)
|
|
158
|
+
.map((model) => ({
|
|
159
|
+
provider,
|
|
160
|
+
id: model.id,
|
|
161
|
+
name: model.name || model.id,
|
|
162
|
+
description: formatModelDescription(model),
|
|
163
|
+
inputModalities: model.input?.includes('image') ? ['text', 'image'] : ['text'],
|
|
164
|
+
}))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @param {string} provider
|
|
169
|
+
* @param {string} model
|
|
170
|
+
* @param {AbortSignal} [_signal]
|
|
171
|
+
* @returns {Promise<Record<string, unknown>>}
|
|
172
|
+
*/
|
|
173
|
+
async resolveModel(provider, model, _signal) {
|
|
174
|
+
return this.resolveModelFromConfig(this.live(), provider, model)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* @param {Record<string, unknown>} cfg
|
|
179
|
+
* @param {string} provider
|
|
180
|
+
* @param {string} model
|
|
181
|
+
* @returns {Record<string, unknown>}
|
|
182
|
+
*/
|
|
183
|
+
resolveModelFromConfig(cfg, provider, model) {
|
|
184
|
+
const pub = publicConfig(cfg)
|
|
185
|
+
const found = findModel(model, pub.dynamicModels, pub.modelContextOverrides, pub.customModels)
|
|
186
|
+
const info = {
|
|
187
|
+
provider,
|
|
188
|
+
id: model,
|
|
189
|
+
name: found?.name || model,
|
|
190
|
+
...(found?.description ? { description: formatModelDescription(found) } : {}),
|
|
191
|
+
inputModalities: found?.input?.includes('image') ? ['text', 'image'] : ['text'],
|
|
192
|
+
context: {
|
|
193
|
+
contextWindow: Number.isFinite(Number(found?.contextLength)) && Number(found.contextLength) > 0
|
|
194
|
+
? Number(found.contextLength)
|
|
195
|
+
: DEFAULT_CONTEXT_WINDOW,
|
|
196
|
+
},
|
|
197
|
+
defaultMaxTokens: Number.isFinite(Number(found?.maxTokens)) && Number(found.maxTokens) > 0
|
|
198
|
+
? Number(found.maxTokens)
|
|
199
|
+
: DEFAULT_MAX_TOKENS,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const efforts = reasoningEfforts(found?.reasoningEfforts)
|
|
203
|
+
if (efforts.length > 0) {
|
|
204
|
+
const defaultEffort = String(pub.modelReasoningDefaults?.[model] || '').trim().toLowerCase()
|
|
205
|
+
info.reasoning = {
|
|
206
|
+
efforts: efforts.map((id) => ({ id: ReasoningEffortId(id), name: reasoningLabel(id) })),
|
|
207
|
+
...(efforts.includes(defaultEffort) ? { defaultEffort: ReasoningEffortId(defaultEffort) } : {}),
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return info
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* @param {string} provider
|
|
215
|
+
* @param {string} model
|
|
216
|
+
* @param {AbortSignal} [signal]
|
|
217
|
+
* @returns {Promise<{ model: Record<string, unknown>, stream: (options: Record<string, unknown>) => AsyncIterable<Record<string, unknown>> }>}
|
|
218
|
+
*/
|
|
219
|
+
async prepareCall(provider, model, signal) {
|
|
220
|
+
signal?.throwIfAborted?.()
|
|
221
|
+
const cfg = this.live()
|
|
222
|
+
const info = this.resolveModelFromConfig(cfg, provider, model)
|
|
223
|
+
return {
|
|
224
|
+
model: info,
|
|
225
|
+
stream: (options) => this.streamWithConfig(options, cfg),
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* @param {Record<string, unknown>} options
|
|
231
|
+
* @returns {AsyncIterable<Record<string, unknown>>}
|
|
232
|
+
*/
|
|
233
|
+
stream(options) {
|
|
234
|
+
return this.streamWithConfig(options, this.live())
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* @param {Record<string, unknown>} options
|
|
239
|
+
* @param {Record<string, unknown>} cfg
|
|
240
|
+
* @returns {AsyncGenerator<Record<string, unknown>>}
|
|
241
|
+
*/
|
|
242
|
+
async *streamWithConfig(options, cfg) {
|
|
243
|
+
const pub = publicConfig(cfg)
|
|
244
|
+
if (!pub.enabled) {
|
|
245
|
+
throw new LlmError('ClineBot provider is disabled', 'PROVIDER_DISABLED')
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const signal = options.signal
|
|
249
|
+
const sessionId = options.sessionId ? String(options.sessionId) : ''
|
|
250
|
+
const model = String(options.model || pub.defaultModel || '')
|
|
251
|
+
const pool = await resolveAccountPool(this.ctx, cfg)
|
|
252
|
+
const configured = pool.filter((account) => account && account.present && account.value)
|
|
253
|
+
if (configured.length === 0) {
|
|
254
|
+
throw new LlmError(
|
|
255
|
+
'No ClinePass API keys are configured. Add a key in Settings -> ClineBot.',
|
|
256
|
+
'MISSING_CREDENTIAL',
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const attachments = this.resolveAttachments
|
|
261
|
+
? this.resolveAttachments()
|
|
262
|
+
: attachmentStore(this.ctx)
|
|
263
|
+
const payload = await buildOpenAiPayload({
|
|
264
|
+
...options,
|
|
265
|
+
model,
|
|
266
|
+
signal,
|
|
267
|
+
}, { attachments })
|
|
268
|
+
|
|
269
|
+
const found = findModel(model, pub.dynamicModels, pub.modelContextOverrides, pub.customModels)
|
|
270
|
+
const modelMax = Number.isFinite(Number(found?.maxTokens)) && Number(found.maxTokens) > 0
|
|
271
|
+
? Number(found.maxTokens)
|
|
272
|
+
: DEFAULT_MAX_TOKENS
|
|
273
|
+
const requestedMax = Number.isFinite(Number(options.maxTokens)) && Number(options.maxTokens) > 0
|
|
274
|
+
? Number(options.maxTokens)
|
|
275
|
+
: undefined
|
|
276
|
+
let maxTokens
|
|
277
|
+
if (pub.maxOutputTokens === 0) {
|
|
278
|
+
// Explicit zero is the "omit max_tokens" sentinel.
|
|
279
|
+
maxTokens = undefined
|
|
280
|
+
} else if (Number.isFinite(Number(pub.maxOutputTokens)) && Number(pub.maxOutputTokens) > 0) {
|
|
281
|
+
maxTokens = Math.min(requestedMax ?? modelMax, Number(pub.maxOutputTokens), modelMax)
|
|
282
|
+
} else {
|
|
283
|
+
maxTokens = Math.min(requestedMax ?? modelMax, modelMax)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const reasoningEffort = options.reasoningEffort
|
|
287
|
+
? String(options.reasoningEffort)
|
|
288
|
+
: (pub.modelReasoningDefaults?.[model] || undefined)
|
|
289
|
+
|
|
290
|
+
const body = {
|
|
291
|
+
model,
|
|
292
|
+
messages: payload.messages,
|
|
293
|
+
stream: true,
|
|
294
|
+
stream_options: { include_usage: true },
|
|
295
|
+
...(payload.tools.length > 0 ? { tools: payload.tools, tool_choice: 'auto' } : {}),
|
|
296
|
+
...(options.temperature !== undefined ? { temperature: options.temperature } : {}),
|
|
297
|
+
...(maxTokens !== undefined ? { max_tokens: maxTokens } : {}),
|
|
298
|
+
...(Array.isArray(options.stop) && options.stop.length > 0 ? { stop: options.stop } : {}),
|
|
299
|
+
...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
|
|
300
|
+
...(sessionId ? {
|
|
301
|
+
session_id: sessionId,
|
|
302
|
+
conversation_id: sessionId,
|
|
303
|
+
prompt_cache_key: sessionId,
|
|
304
|
+
} : {}),
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const extraHeaders = sessionId
|
|
308
|
+
? { 'x-dsh-session-id': sessionId, 'x-session-id': sessionId }
|
|
309
|
+
: {}
|
|
310
|
+
|
|
311
|
+
const maxAttempts = configured.length
|
|
312
|
+
let lastError = null
|
|
313
|
+
|
|
314
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
315
|
+
if (signal?.aborted) throw abortError()
|
|
316
|
+
const route = resolveSessionAccount(
|
|
317
|
+
sessionId,
|
|
318
|
+
pool,
|
|
319
|
+
pub.activeAccount,
|
|
320
|
+
pub.accountMode,
|
|
321
|
+
model,
|
|
322
|
+
pub.modelRoutes,
|
|
323
|
+
)
|
|
324
|
+
const account = route.account
|
|
325
|
+
if (!account?.value) {
|
|
326
|
+
lastError = new LlmError('No available ClinePass API key for this session', 'MISSING_CREDENTIAL')
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
const accountId = account.apiKeyEnv || account.id || account.label
|
|
330
|
+
const startedAt = Date.now()
|
|
331
|
+
let ttftMs = null
|
|
332
|
+
let usage = null
|
|
333
|
+
|
|
334
|
+
let response
|
|
335
|
+
try {
|
|
336
|
+
response = await requestChatCompletion({
|
|
337
|
+
baseUrl: pub.baseUrl,
|
|
338
|
+
apiKey: account.value,
|
|
339
|
+
body,
|
|
340
|
+
signal,
|
|
341
|
+
connectTimeoutMs: pub.timeoutMs,
|
|
342
|
+
extraHeaders,
|
|
343
|
+
})
|
|
344
|
+
} catch (error) {
|
|
345
|
+
if (isAbortError(error) || signal?.aborted) throw abortError()
|
|
346
|
+
lastError = this.transportError(error, 'ClinePass connection failed')
|
|
347
|
+
if (attempt + 1 < maxAttempts) {
|
|
348
|
+
markAccountCooldown(accountId, COOLDOWN_MS, null, 'upstream_timeout')
|
|
349
|
+
continue
|
|
350
|
+
}
|
|
351
|
+
throw lastError
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (!response.ok) {
|
|
355
|
+
const text = await readResponseText(response).catch(() => '')
|
|
356
|
+
const classified = classifyUpstreamStatus(response.status, text)
|
|
357
|
+
const retryAfter = retryAfterMs(response.headers)
|
|
358
|
+
lastError = new LlmError(classified.message, classified.code, {
|
|
359
|
+
status: response.status,
|
|
360
|
+
...(retryAfter ? { providerRetryAfterMs: retryAfter } : {}),
|
|
361
|
+
})
|
|
362
|
+
if (classified.retryable && attempt + 1 < maxAttempts) {
|
|
363
|
+
markAccountCooldown(
|
|
364
|
+
accountId,
|
|
365
|
+
retryAfter || COOLDOWN_MS,
|
|
366
|
+
retryAfter ? Date.now() + retryAfter : null,
|
|
367
|
+
`upstream_${response.status}`,
|
|
368
|
+
)
|
|
369
|
+
continue
|
|
370
|
+
}
|
|
371
|
+
throw lastError
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const onUsage = (nextUsage) => {
|
|
375
|
+
usage = nextUsage
|
|
376
|
+
}
|
|
377
|
+
const onFirstToken = () => {
|
|
378
|
+
if (ttftMs === null) ttftMs = Date.now() - startedAt
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
if (isEventStream(response)) {
|
|
383
|
+
const events = readSseEvents(response, {
|
|
384
|
+
signal,
|
|
385
|
+
idleTimeoutMs: pub.streamIdleTimeoutMs,
|
|
386
|
+
})
|
|
387
|
+
const first = await events.next()
|
|
388
|
+
if (first.done) {
|
|
389
|
+
throw new LlmError('ClinePass returned an empty stream', 'EMPTY_RESPONSE')
|
|
390
|
+
}
|
|
391
|
+
const firstEvent = first.value
|
|
392
|
+
if (firstEvent && typeof firstEvent === 'object' && 'error' in firstEvent) {
|
|
393
|
+
const providerError = firstEvent.error || {}
|
|
394
|
+
throw new LlmError(
|
|
395
|
+
String(providerError.message || 'ClinePass stream returned an error'),
|
|
396
|
+
String(providerError.code || 'PROVIDER_ERROR'),
|
|
397
|
+
Number.isFinite(Number(providerError.status)) ? { status: Number(providerError.status) } : {},
|
|
398
|
+
)
|
|
399
|
+
}
|
|
400
|
+
if (firstEvent && typeof firstEvent === 'object' && firstEvent.done === true) {
|
|
401
|
+
throw new LlmError('ClinePass returned an empty stream', 'EMPTY_RESPONSE')
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const iterator = streamOpenAiChunks(
|
|
405
|
+
prependAsync(firstEvent, events),
|
|
406
|
+
{ signal, onUsage, onFirstToken },
|
|
407
|
+
)
|
|
408
|
+
const buffered = []
|
|
409
|
+
let committed = false
|
|
410
|
+
let terminal = null
|
|
411
|
+
while (true) {
|
|
412
|
+
const next = await iterator.next()
|
|
413
|
+
if (next.done) break
|
|
414
|
+
buffered.push(next.value)
|
|
415
|
+
if (isContentChunk(next.value)) {
|
|
416
|
+
committed = true
|
|
417
|
+
break
|
|
418
|
+
}
|
|
419
|
+
if (next.value?.type === 'finish') {
|
|
420
|
+
terminal = next.value
|
|
421
|
+
break
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (!committed) {
|
|
426
|
+
const failure = terminal?.reason?.failure
|
|
427
|
+
throw new LlmError(
|
|
428
|
+
String(failure?.message || 'ClinePass returned no content before finishing'),
|
|
429
|
+
String(failure?.code || 'EMPTY_RESPONSE'),
|
|
430
|
+
)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
for (const chunk of buffered) yield chunk
|
|
434
|
+
for await (const chunk of iterator) yield chunk
|
|
435
|
+
|
|
436
|
+
this.recordAttempt({
|
|
437
|
+
pub,
|
|
438
|
+
accountId,
|
|
439
|
+
model,
|
|
440
|
+
usage,
|
|
441
|
+
isError: false,
|
|
442
|
+
ttftMs,
|
|
443
|
+
latencyMs: Date.now() - startedAt,
|
|
444
|
+
options,
|
|
445
|
+
})
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Fallback for deployments that ignore `stream: true`.
|
|
450
|
+
const text = await readResponseText(response)
|
|
451
|
+
let json
|
|
452
|
+
try {
|
|
453
|
+
json = JSON.parse(text)
|
|
454
|
+
} catch {
|
|
455
|
+
throw new LlmError('ClinePass returned a non-JSON completion', 'PROVIDER_ERROR')
|
|
456
|
+
}
|
|
457
|
+
let committed = false
|
|
458
|
+
for await (const chunk of streamJsonCompletion(json, { onUsage, onFirstToken })) {
|
|
459
|
+
if (isContentChunk(chunk)) committed = true
|
|
460
|
+
yield chunk
|
|
461
|
+
}
|
|
462
|
+
if (!committed) {
|
|
463
|
+
throw new LlmError('ClinePass returned an empty completion', 'EMPTY_RESPONSE')
|
|
464
|
+
}
|
|
465
|
+
this.recordAttempt({
|
|
466
|
+
pub,
|
|
467
|
+
accountId,
|
|
468
|
+
model,
|
|
469
|
+
usage,
|
|
470
|
+
isError: false,
|
|
471
|
+
ttftMs,
|
|
472
|
+
latencyMs: Date.now() - startedAt,
|
|
473
|
+
options,
|
|
474
|
+
})
|
|
475
|
+
return
|
|
476
|
+
} catch (error) {
|
|
477
|
+
if (isAbortError(error) || signal?.aborted) {
|
|
478
|
+
this.recordAttempt({
|
|
479
|
+
pub,
|
|
480
|
+
accountId,
|
|
481
|
+
model,
|
|
482
|
+
usage,
|
|
483
|
+
isError: true,
|
|
484
|
+
ttftMs,
|
|
485
|
+
latencyMs: Date.now() - startedAt,
|
|
486
|
+
options,
|
|
487
|
+
aborted: true,
|
|
488
|
+
})
|
|
489
|
+
throw abortError()
|
|
490
|
+
}
|
|
491
|
+
const normalized = error instanceof LlmError
|
|
492
|
+
? error
|
|
493
|
+
: this.transportError(error, 'ClinePass stream failed')
|
|
494
|
+
const retryable = normalized.code === 'RATE_LIMIT'
|
|
495
|
+
|| normalized.code === 'PROVIDER_ERROR'
|
|
496
|
+
|| normalized.code === 'TRANSPORT_ERROR'
|
|
497
|
+
|| normalized.code === 'EMPTY_RESPONSE'
|
|
498
|
+
lastError = normalized
|
|
499
|
+
if (retryable && attempt + 1 < maxAttempts) {
|
|
500
|
+
markAccountCooldown(accountId, COOLDOWN_MS, null, 'upstream_pre_token_error')
|
|
501
|
+
continue
|
|
502
|
+
}
|
|
503
|
+
this.recordAttempt({
|
|
504
|
+
pub,
|
|
505
|
+
accountId,
|
|
506
|
+
model,
|
|
507
|
+
usage,
|
|
508
|
+
isError: true,
|
|
509
|
+
is429: normalized.code === 'RATE_LIMIT',
|
|
510
|
+
ttftMs,
|
|
511
|
+
latencyMs: Date.now() - startedAt,
|
|
512
|
+
options,
|
|
513
|
+
})
|
|
514
|
+
throw normalized
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
throw lastError || new LlmError('All ClinePass accounts failed', 'PROVIDER_ERROR')
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Convert an unknown transport failure into `LlmError`.
|
|
523
|
+
* @param {unknown} error
|
|
524
|
+
* @param {string} fallbackMessage
|
|
525
|
+
* @returns {LlmError}
|
|
526
|
+
*/
|
|
527
|
+
transportError(error, fallbackMessage) {
|
|
528
|
+
const message = String(error?.message || error || fallbackMessage)
|
|
529
|
+
const code = String(error?.code || 'TRANSPORT_ERROR')
|
|
530
|
+
return new LlmError(message, code === 'CONNECT_TIMEOUT' ? 'TRANSPORT_ERROR' : code)
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Record one attempt's session and persistent telemetry.
|
|
535
|
+
* @param {{
|
|
536
|
+
* pub: Record<string, unknown>,
|
|
537
|
+
* accountId: string,
|
|
538
|
+
* model: string,
|
|
539
|
+
* usage: ReturnType<typeof import('./transport/cline-transport.js').extractUsage> | null,
|
|
540
|
+
* isError: boolean,
|
|
541
|
+
* is429?: boolean,
|
|
542
|
+
* ttftMs: number | null,
|
|
543
|
+
* latencyMs: number,
|
|
544
|
+
* aborted?: boolean,
|
|
545
|
+
* options: Record<string, unknown>,
|
|
546
|
+
* }} entry
|
|
547
|
+
*/
|
|
548
|
+
recordAttempt({
|
|
549
|
+
pub,
|
|
550
|
+
accountId,
|
|
551
|
+
model,
|
|
552
|
+
usage,
|
|
553
|
+
isError,
|
|
554
|
+
is429 = false,
|
|
555
|
+
ttftMs,
|
|
556
|
+
latencyMs,
|
|
557
|
+
aborted = false,
|
|
558
|
+
options,
|
|
559
|
+
}) {
|
|
560
|
+
const promptTokens = Number(usage?.promptTokens || 0)
|
|
561
|
+
const completionTokens = Number(usage?.completionTokens || 0)
|
|
562
|
+
const reasoningTokens = Number(usage?.reasoningTokens || 0)
|
|
563
|
+
const totalTokens = Number(usage?.totalTokens || (promptTokens + completionTokens))
|
|
564
|
+
const cost = estimateCost({
|
|
565
|
+
model,
|
|
566
|
+
promptTokens,
|
|
567
|
+
completionTokens,
|
|
568
|
+
reasoningTokens,
|
|
569
|
+
cacheReadTokens: usage?.cacheReadTokens,
|
|
570
|
+
cacheWriteTokens: usage?.cacheWriteTokens,
|
|
571
|
+
overrides: pub.priceOverrides || {},
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
try {
|
|
575
|
+
recordSessionRequest({
|
|
576
|
+
latencyMs,
|
|
577
|
+
ok: !isError,
|
|
578
|
+
aborted,
|
|
579
|
+
promptTokens,
|
|
580
|
+
completionTokens,
|
|
581
|
+
reasoningTokens,
|
|
582
|
+
model,
|
|
583
|
+
...(isError ? { error: is429 ? 'rate limit' : 'stream error' } : {}),
|
|
584
|
+
})
|
|
585
|
+
} catch {
|
|
586
|
+
/* telemetry must never break inference */
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
try {
|
|
590
|
+
recordUsage({
|
|
591
|
+
model,
|
|
592
|
+
accountId,
|
|
593
|
+
promptTokens,
|
|
594
|
+
completionTokens,
|
|
595
|
+
reasoningTokens,
|
|
596
|
+
totalTokens,
|
|
597
|
+
isError,
|
|
598
|
+
is429,
|
|
599
|
+
statsPath: pub.statsPath,
|
|
600
|
+
// Extended fields consumed by stats v2 once available.
|
|
601
|
+
sessionId: options.sessionId ? String(options.sessionId) : undefined,
|
|
602
|
+
purpose: options.purpose,
|
|
603
|
+
ttftMs: ttftMs ?? undefined,
|
|
604
|
+
latencyMs,
|
|
605
|
+
cacheReadTokens: usage?.cacheReadTokens,
|
|
606
|
+
cacheWriteTokens: usage?.cacheWriteTokens,
|
|
607
|
+
costUsd: cost.usd,
|
|
608
|
+
priced: cost.priced,
|
|
609
|
+
})
|
|
610
|
+
} catch {
|
|
611
|
+
/* persistent telemetry is best-effort */
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export { PROVIDER, PROVIDER_NAME }
|