@goodandready/dsh-clinebot 0.5.33 → 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/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 }