@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.
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Convert ClinePass OpenAI-compatible stream events into DSH `StreamChunk`s.
3
+ *
4
+ * The adapter owns the block lifecycle: it opens a block on the first delta,
5
+ * forwards deltas, then closes every open block before the terminal finish.
6
+ * Tool-call arguments remain raw JSON strings, as required by the DSH protocol.
7
+ */
8
+
9
+ import { LlmError, ToolCallId } from '@deepseek-ai/dsh-llm'
10
+ import { extractUsage, isAbortError } from './transport/cline-transport.js'
11
+
12
+ /**
13
+ * Keep only fields the DSH `TokenUsage` contract accepts.
14
+ * @param {ReturnType<typeof extractUsage>} usage
15
+ * @returns {Record<string, number>}
16
+ */
17
+ function wireUsage(usage) {
18
+ return {
19
+ inputTokens: usage.inputTokens,
20
+ outputTokens: usage.outputTokens,
21
+ ...(usage.totalTokens !== undefined ? { totalTokens: usage.totalTokens } : {}),
22
+ ...(usage.cacheReadTokens !== undefined ? { cacheReadTokens: usage.cacheReadTokens } : {}),
23
+ ...(usage.cacheWriteTokens !== undefined ? { cacheWriteTokens: usage.cacheWriteTokens } : {}),
24
+ ...(usage.reasoningTokens !== undefined ? { reasoningTokens: usage.reasoningTokens } : {}),
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Map a provider finish reason to a DSH finish chunk.
30
+ * @param {string} finishReason
31
+ * @param {boolean} hasToolCalls
32
+ * @returns {{ type: 'finish', reason: Record<string, unknown> }}
33
+ */
34
+ function finishChunk(finishReason, hasToolCalls) {
35
+ if (hasToolCalls || finishReason === 'tool_calls') {
36
+ return { type: 'finish', reason: { kind: 'tool-calls' } }
37
+ }
38
+ if (finishReason === 'length' || finishReason === 'max_tokens') {
39
+ return { type: 'finish', reason: { kind: 'max-tokens' } }
40
+ }
41
+ return { type: 'finish', reason: { kind: 'stop' } }
42
+ }
43
+
44
+ /**
45
+ * Stream one OpenAI-compatible SSE response as DSH chunks.
46
+ *
47
+ * @param {AsyncIterable<Record<string, unknown> | { done: true } | { raw: string }>} events
48
+ * @param {{
49
+ * signal?: AbortSignal,
50
+ * onUsage?: (usage: ReturnType<typeof extractUsage>) => void,
51
+ * onFirstToken?: () => void,
52
+ * }} [options]
53
+ * @returns {AsyncGenerator<Record<string, unknown>>}
54
+ */
55
+ export async function* streamOpenAiChunks(events, { signal, onUsage, onFirstToken } = {}) {
56
+ let nextIndex = 0
57
+ let textIndex = -1
58
+ let reasoningIndex = -1
59
+ let text = ''
60
+ let reasoning = ''
61
+ const toolBlocks = new Map()
62
+ let finishReason = ''
63
+ let sawContent = false
64
+ let aborted = false
65
+
66
+ try {
67
+ for await (const event of events) {
68
+ if (signal?.aborted) {
69
+ aborted = true
70
+ break
71
+ }
72
+ if (!event || typeof event !== 'object') continue
73
+ if (event.done === true) break
74
+ if (event.raw !== undefined) continue
75
+ if (event.error) {
76
+ const error = event.error
77
+ throw new LlmError(
78
+ String(error.message || 'ClinePass stream returned an error'),
79
+ String(error.code || 'PROVIDER_ERROR'),
80
+ {
81
+ ...(Number.isFinite(Number(error.status)) ? { status: Number(error.status) } : {}),
82
+ },
83
+ )
84
+ }
85
+
86
+ if (event.usage) {
87
+ const usage = extractUsage(event.usage)
88
+ onUsage?.(usage)
89
+ yield { type: 'usage', usage: wireUsage(usage) }
90
+ }
91
+
92
+ const choice = Array.isArray(event.choices) ? event.choices[0] : null
93
+ if (!choice) continue
94
+ const delta = choice.delta && typeof choice.delta === 'object' ? choice.delta : {}
95
+
96
+ const reasoningText = typeof delta.reasoning_content === 'string'
97
+ ? delta.reasoning_content
98
+ : (typeof delta.reasoning === 'string' ? delta.reasoning : '')
99
+ if (reasoningText) {
100
+ if (reasoningIndex < 0) {
101
+ reasoningIndex = nextIndex++
102
+ yield { type: 'block-start', index: reasoningIndex, blockType: 'reasoning' }
103
+ }
104
+ reasoning += reasoningText
105
+ if (!sawContent) onFirstToken?.()
106
+ sawContent = true
107
+ yield { type: 'reasoning-delta', index: reasoningIndex, text: reasoningText }
108
+ }
109
+
110
+ const contentText = typeof delta.content === 'string' ? delta.content : ''
111
+ if (contentText) {
112
+ if (textIndex < 0) {
113
+ textIndex = nextIndex++
114
+ yield { type: 'block-start', index: textIndex, blockType: 'text' }
115
+ }
116
+ text += contentText
117
+ if (!sawContent) onFirstToken?.()
118
+ sawContent = true
119
+ yield { type: 'text-delta', index: textIndex, text: contentText }
120
+ }
121
+
122
+ if (Array.isArray(delta.tool_calls)) {
123
+ for (const call of delta.tool_calls) {
124
+ if (!call || typeof call !== 'object') continue
125
+ const providerIndex = Number.isInteger(call.index) ? call.index : toolBlocks.size
126
+ let block = toolBlocks.get(providerIndex)
127
+ if (!block) {
128
+ block = {
129
+ index: nextIndex++,
130
+ id: typeof call.id === 'string' && call.id ? call.id : `clinebot_call_${providerIndex}`,
131
+ name: '',
132
+ arguments: '',
133
+ }
134
+ toolBlocks.set(providerIndex, block)
135
+ yield { type: 'block-start', index: block.index, blockType: 'tool-call' }
136
+ }
137
+ if (typeof call.id === 'string' && call.id) block.id = call.id
138
+ const name = typeof call.function?.name === 'string' ? call.function.name : ''
139
+ if (name) block.name = name
140
+ const argumentsDelta = typeof call.function?.arguments === 'string' ? call.function.arguments : ''
141
+ block.arguments += argumentsDelta
142
+ if (!sawContent) onFirstToken?.()
143
+ sawContent = true
144
+ yield {
145
+ type: 'tool-call-delta',
146
+ index: block.index,
147
+ id: ToolCallId(block.id),
148
+ ...(name ? { name } : {}),
149
+ argumentsDelta,
150
+ }
151
+ }
152
+ }
153
+
154
+ if (typeof choice.finish_reason === 'string' && choice.finish_reason) {
155
+ finishReason = choice.finish_reason
156
+ }
157
+ }
158
+ } catch (error) {
159
+ if (isAbortError(error)) {
160
+ yield { type: 'finish', reason: { kind: 'aborted', failure: { message: 'Request aborted', code: 'ABORTED' } } }
161
+ return
162
+ }
163
+ throw error
164
+ }
165
+
166
+ if (aborted || signal?.aborted) {
167
+ yield { type: 'finish', reason: { kind: 'aborted', failure: { message: 'Request aborted', code: 'ABORTED' } } }
168
+ return
169
+ }
170
+
171
+ // Close every block that was opened. DSH's assembler consumes block-end as
172
+ // the authoritative final content of that block.
173
+ if (textIndex >= 0) {
174
+ yield { type: 'block-end', index: textIndex, block: { type: 'text', text } }
175
+ }
176
+ if (reasoningIndex >= 0) {
177
+ yield { type: 'block-end', index: reasoningIndex, block: { type: 'reasoning', text: reasoning } }
178
+ }
179
+ for (const block of toolBlocks.values()) {
180
+ yield {
181
+ type: 'block-end',
182
+ index: block.index,
183
+ block: {
184
+ type: 'tool-call',
185
+ id: ToolCallId(block.id),
186
+ name: block.name,
187
+ arguments: block.arguments,
188
+ },
189
+ }
190
+ }
191
+
192
+ if (!sawContent) {
193
+ yield {
194
+ type: 'finish',
195
+ reason: {
196
+ kind: 'error',
197
+ failure: { message: 'ClinePass returned an empty completion', code: 'EMPTY_RESPONSE' },
198
+ },
199
+ }
200
+ return
201
+ }
202
+
203
+ yield finishChunk(finishReason, toolBlocks.size > 0)
204
+ }
205
+
206
+ /**
207
+ * Convert a non-streaming OpenAI-compatible completion into DSH chunks.
208
+ * Used as a fallback when ClinePass ignores `stream: true` or returns JSON.
209
+ *
210
+ * @param {Record<string, unknown>} json
211
+ * @param {{ onUsage?: (usage: ReturnType<typeof extractUsage>) => void, onFirstToken?: () => void }} [options]
212
+ * @returns {AsyncGenerator<Record<string, unknown>>}
213
+ */
214
+ export async function* streamJsonCompletion(json, { onUsage, onFirstToken } = {}) {
215
+ if (json?.error) {
216
+ throw new LlmError(
217
+ String(json.error.message || 'ClinePass returned an error'),
218
+ String(json.error.code || 'PROVIDER_ERROR'),
219
+ Number.isFinite(Number(json.error.status)) ? { status: Number(json.error.status) } : {},
220
+ )
221
+ }
222
+ const choice = Array.isArray(json?.choices) ? json.choices[0] : null
223
+ if (!choice) {
224
+ yield {
225
+ type: 'finish',
226
+ reason: {
227
+ kind: 'error',
228
+ failure: { message: 'ClinePass returned an empty completion', code: 'EMPTY_RESPONSE' },
229
+ },
230
+ }
231
+ return
232
+ }
233
+ const message = choice.message && typeof choice.message === 'object' ? choice.message : {}
234
+ let nextIndex = 0
235
+ let sawContent = false
236
+
237
+ if (json.usage) {
238
+ const usage = extractUsage(json.usage)
239
+ onUsage?.(usage)
240
+ yield { type: 'usage', usage: wireUsage(usage) }
241
+ }
242
+
243
+ const reasoning = typeof message.reasoning_content === 'string'
244
+ ? message.reasoning_content
245
+ : (typeof message.reasoning === 'string' ? message.reasoning : '')
246
+ if (reasoning) {
247
+ const index = nextIndex++
248
+ yield { type: 'block-start', index, blockType: 'reasoning' }
249
+ yield { type: 'reasoning-delta', index, text: reasoning }
250
+ yield { type: 'block-end', index, block: { type: 'reasoning', text: reasoning } }
251
+ sawContent = true
252
+ }
253
+
254
+ const content = typeof message.content === 'string' ? message.content : ''
255
+ if (content) {
256
+ const index = nextIndex++
257
+ yield { type: 'block-start', index, blockType: 'text' }
258
+ yield { type: 'text-delta', index, text: content }
259
+ yield { type: 'block-end', index, block: { type: 'text', text: content } }
260
+ sawContent = true
261
+ }
262
+
263
+ let hasToolCalls = false
264
+ if (Array.isArray(message.tool_calls)) {
265
+ for (const call of message.tool_calls) {
266
+ if (!call || typeof call !== 'object') continue
267
+ hasToolCalls = true
268
+ const id = typeof call.id === 'string' && call.id ? call.id : `clinebot_call_${nextIndex}`
269
+ const name = typeof call.function?.name === 'string' ? call.function.name : ''
270
+ const args = typeof call.function?.arguments === 'string' ? call.function.arguments : ''
271
+ const index = nextIndex++
272
+ yield { type: 'block-start', index, blockType: 'tool-call' }
273
+ yield { type: 'tool-call-delta', index, id: ToolCallId(id), ...(name ? { name } : {}), argumentsDelta: args }
274
+ yield { type: 'block-end', index, block: { type: 'tool-call', id: ToolCallId(id), name, arguments: args } }
275
+ sawContent = true
276
+ }
277
+ }
278
+
279
+ if (!sawContent) {
280
+ yield {
281
+ type: 'finish',
282
+ reason: {
283
+ kind: 'error',
284
+ failure: { message: 'ClinePass returned an empty completion', code: 'EMPTY_RESPONSE' },
285
+ },
286
+ }
287
+ return
288
+ }
289
+
290
+ onFirstToken?.()
291
+ yield finishChunk(String(choice.finish_reason || ''), hasToolCalls)
292
+ }