dsh-cline-pass 0.1.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/LICENSE +28 -0
- package/README.en.md +124 -0
- package/README.md +124 -0
- package/cordis.patch.yml +27 -0
- package/lib/adapter.js +620 -0
- package/lib/catalog.js +193 -0
- package/lib/client.js +945 -0
- package/lib/cline.js +194 -0
- package/lib/engine.js +405 -0
- package/lib/index.js +409 -0
- package/lib/panel.js +464 -0
- package/lib/protocol.js +226 -0
- package/lib/store.js +92 -0
- package/lib/tools.js +756 -0
- package/package.json +72 -0
package/lib/adapter.js
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Cline Pass LLM adapter: an OpenAI-compatible `chat/completions` stream
|
|
3
|
+
* with upstream pinning and pre-first-token failover.
|
|
4
|
+
*
|
|
5
|
+
* Transport only. Connection facts, the per-request account, the per-model pin,
|
|
6
|
+
* and the observation callbacks all arrive as thunks from `index.js`, which
|
|
7
|
+
* keeps credential policy, settings layering, and state out of here.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-cline-pass/adapter
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
attributionHeaders,
|
|
13
|
+
contentHasImage,
|
|
14
|
+
CONTEXT_WINDOW_EXCEEDED_CODE,
|
|
15
|
+
EMPTY_RESPONSE_CODE,
|
|
16
|
+
errorChain,
|
|
17
|
+
isContextWindowExceededError,
|
|
18
|
+
isQuotaExceededError,
|
|
19
|
+
LlmAdapter,
|
|
20
|
+
LlmError,
|
|
21
|
+
QUOTA_EXCEEDED_CODE,
|
|
22
|
+
ToolCallId,
|
|
23
|
+
} from '@deepseek-ai/dsh-llm'
|
|
24
|
+
import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
|
25
|
+
import { chatURL } from './cline.js'
|
|
26
|
+
import { resolveModelMetadata } from './catalog.js'
|
|
27
|
+
import {
|
|
28
|
+
buildAttempts,
|
|
29
|
+
classifyUpstreamError,
|
|
30
|
+
errorText,
|
|
31
|
+
injectPrefs,
|
|
32
|
+
parseRouting,
|
|
33
|
+
unwrapEnvelope,
|
|
34
|
+
} from './protocol.js'
|
|
35
|
+
import { parseServerSentEvents } from './engine.js'
|
|
36
|
+
|
|
37
|
+
/** Default maximum idle interval while a stream read is outstanding. */
|
|
38
|
+
export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300000
|
|
39
|
+
/**
|
|
40
|
+
* Conservative context capacity for a model the catalog does not describe.
|
|
41
|
+
* Shipped models carry their published window, so this only bounds the
|
|
42
|
+
* unknown ones; raise it per model in configuration when a new model ships.
|
|
43
|
+
*/
|
|
44
|
+
export const DEFAULT_CONTEXT_WINDOW = 128000
|
|
45
|
+
/**
|
|
46
|
+
* Conservative per-request output cap for a model the catalog does not
|
|
47
|
+
* describe. It must stay well below the smallest published window, because
|
|
48
|
+
* the default is materialized into requests whose callers omit `maxTokens`.
|
|
49
|
+
*/
|
|
50
|
+
export const DEFAULT_MAX_TOKENS = 32000
|
|
51
|
+
/**
|
|
52
|
+
* Request-image budget applied when the caller names none.
|
|
53
|
+
*
|
|
54
|
+
* Every image in the history is re-encoded into every request body, so an
|
|
55
|
+
* unbounded conversation would eventually exceed the gateway's request-size
|
|
56
|
+
* cap. These match the values the shipped adapters use.
|
|
57
|
+
*/
|
|
58
|
+
export const DEFAULT_REQUEST_IMAGE_POLICY = { maxPixels: 4194304, maxBytes: 1048576 }
|
|
59
|
+
|
|
60
|
+
const STREAM_IDLE_TIMEOUT_CODE = 'CLINE_PASS_STREAM_IDLE_TIMEOUT'
|
|
61
|
+
|
|
62
|
+
//#region request serialization
|
|
63
|
+
|
|
64
|
+
/** Join a message's text blocks. */
|
|
65
|
+
function flattenText(blocks) {
|
|
66
|
+
return (blocks ?? []).filter((block) => block.type === 'text').map((block) => block.text).join('')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Encode request image bytes for an inline data URI. */
|
|
70
|
+
function toBase64(bytes) {
|
|
71
|
+
return Buffer.from(bytes).toString('base64')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Collect every image reference in a content list, keyed by attachment id.
|
|
76
|
+
*
|
|
77
|
+
* The walk descends into tool-result content, because an image can arrive as
|
|
78
|
+
* the result of a tool call rather than as a direct user attachment.
|
|
79
|
+
*
|
|
80
|
+
* @param blocks - typed content blocks.
|
|
81
|
+
* @param refs - accumulator, keyed by attachment id.
|
|
82
|
+
* @returns the accumulator.
|
|
83
|
+
*/
|
|
84
|
+
export function collectImageRefs(blocks, refs = new Map()) {
|
|
85
|
+
for (const block of blocks ?? []) {
|
|
86
|
+
if (block.type === 'image') refs.set(block.attachment.attachmentId, block.attachment)
|
|
87
|
+
else if (block.type === 'tool-result') collectImageRefs(block.content, refs)
|
|
88
|
+
}
|
|
89
|
+
return refs
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Read one provider-request version of every image in the history.
|
|
94
|
+
*
|
|
95
|
+
* The harness hands adapters attachment references, never bytes, so the bytes
|
|
96
|
+
* are materialized here at the policy the provider needs. A history with no
|
|
97
|
+
* image resolves nothing and costs no call.
|
|
98
|
+
*
|
|
99
|
+
* @returns attachmentId → prepared version, or undefined when there is no image.
|
|
100
|
+
* @throws {LlmError} UNSUPPORTED_CONTENT when images exist but no store is composed.
|
|
101
|
+
*/
|
|
102
|
+
export async function prepareRequestImages(messages, attachments, policy, signal) {
|
|
103
|
+
const refs = new Map()
|
|
104
|
+
for (const message of messages ?? []) collectImageRefs(message.content, refs)
|
|
105
|
+
if (refs.size === 0) return undefined
|
|
106
|
+
if (attachments === undefined) {
|
|
107
|
+
throw new LlmError('The Cline Pass adapter needs the durable attachment service to send image input.', 'UNSUPPORTED_CONTENT')
|
|
108
|
+
}
|
|
109
|
+
const ordered = [...refs.values()]
|
|
110
|
+
const prepared = await Promise.all(ordered.map((ref) => attachments.readImageRequest(ref, policy, signal)))
|
|
111
|
+
const versions = new Map()
|
|
112
|
+
for (const [index, ref] of ordered.entries()) versions.set(ref.attachmentId, prepared[index])
|
|
113
|
+
return versions
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Convert one assistant message, keeping tool calls and reasoning. */
|
|
117
|
+
function serializeAssistant(message) {
|
|
118
|
+
// A structured image in assistant output has no OpenAI wire spelling, so it
|
|
119
|
+
// fails loudly rather than being silently dropped.
|
|
120
|
+
if (contentHasImage(message.content ?? [])) {
|
|
121
|
+
throw new LlmError('The Cline Pass adapter cannot represent structured assistant image output.', 'UNSUPPORTED_CONTENT')
|
|
122
|
+
}
|
|
123
|
+
const text = flattenText(message.content)
|
|
124
|
+
const reasoning = (message.content ?? []).filter((block) => block.type === 'reasoning').map((block) => block.text).join('')
|
|
125
|
+
const toolCalls = (message.content ?? []).filter((block) => block.type === 'tool-call').map((block) => ({
|
|
126
|
+
id: block.id,
|
|
127
|
+
type: 'function',
|
|
128
|
+
function: { name: block.name, arguments: block.arguments },
|
|
129
|
+
}))
|
|
130
|
+
return {
|
|
131
|
+
role: 'assistant',
|
|
132
|
+
content: text,
|
|
133
|
+
...(reasoning.length > 0 ? { reasoning_content: reasoning } : {}),
|
|
134
|
+
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Convert the harness conversation to wire messages.
|
|
140
|
+
*
|
|
141
|
+
* Tool results become standalone `{ role: 'tool' }` messages. A user message
|
|
142
|
+
* carrying an image switches to the OpenAI content-part array; a text-only one
|
|
143
|
+
* keeps the plain-string form.
|
|
144
|
+
*/
|
|
145
|
+
function serializeMessages(messages, images) {
|
|
146
|
+
const wire = []
|
|
147
|
+
for (const message of messages) {
|
|
148
|
+
if (message.role === 'system') {
|
|
149
|
+
wire.push({ role: 'system', content: flattenText(message.content) })
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
if (message.role === 'assistant') {
|
|
153
|
+
wire.push(serializeAssistant(message))
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
const toolResults = (message.content ?? []).filter((block) => block.type === 'tool-result')
|
|
157
|
+
const parts = []
|
|
158
|
+
for (const block of message.content ?? []) {
|
|
159
|
+
if (block.type === 'text' && block.text.length > 0) parts.push({ type: 'text', text: block.text })
|
|
160
|
+
else if (block.type === 'image') {
|
|
161
|
+
const version = images?.get(block.attachment?.attachmentId)
|
|
162
|
+
if (version === undefined) {
|
|
163
|
+
throw new LlmError('The Cline Pass adapter received an image it could not resolve into request bytes.', 'UNSUPPORTED_CONTENT')
|
|
164
|
+
}
|
|
165
|
+
parts.push({ type: 'image_url', image_url: { url: `data:${version.mediaType};base64,${toBase64(version.data)}` } })
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const hasImage = parts.some((part) => part.type === 'image_url')
|
|
169
|
+
const text = parts.filter((part) => part.type === 'text').map((part) => part.text).join('')
|
|
170
|
+
if (hasImage) wire.push({ role: 'user', content: parts })
|
|
171
|
+
else if (text.length > 0 || toolResults.length === 0) wire.push({ role: 'user', content: text })
|
|
172
|
+
for (const result of toolResults) {
|
|
173
|
+
wire.push({
|
|
174
|
+
role: 'tool',
|
|
175
|
+
tool_call_id: result.toolCallId,
|
|
176
|
+
content: flattenText(result.content) || '(no output)',
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return wire
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Build the wire request for one harness call, before pinning.
|
|
185
|
+
* @param options - the harness request (model, system, history, tools, sampling).
|
|
186
|
+
* @param connection - resolved connection facts (default token caps).
|
|
187
|
+
* @param images - attachmentId → prepared request version, when the call has images.
|
|
188
|
+
*/
|
|
189
|
+
export function buildRequestBody(options, connection, images) {
|
|
190
|
+
const messages = []
|
|
191
|
+
if (options.system !== undefined) messages.push({ role: 'system', content: options.system })
|
|
192
|
+
messages.push(...serializeMessages(options.messages, images))
|
|
193
|
+
const tools = (options.tools ?? []).map((tool) => ({
|
|
194
|
+
type: 'function',
|
|
195
|
+
function: { name: tool.name, description: tool.description, parameters: tool.parameters },
|
|
196
|
+
}))
|
|
197
|
+
return {
|
|
198
|
+
model: options.model,
|
|
199
|
+
messages,
|
|
200
|
+
stream: true,
|
|
201
|
+
stream_options: { include_usage: true },
|
|
202
|
+
...(tools.length > 0 ? { tools } : {}),
|
|
203
|
+
...(options.temperature === undefined ? {} : { temperature: options.temperature }),
|
|
204
|
+
...(options.maxTokens === undefined ? {} : { max_tokens: options.maxTokens }),
|
|
205
|
+
...(options.stop === undefined ? {} : { stop: options.stop }),
|
|
206
|
+
...(options.reasoningEffort === undefined ? {} : { reasoning_effort: options.reasoningEffort }),
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
//#endregion
|
|
211
|
+
|
|
212
|
+
//#region stream translation
|
|
213
|
+
|
|
214
|
+
/** Map an OpenAI `finish_reason` to the harness finish reason. */
|
|
215
|
+
export function mapFinishReason(reason) {
|
|
216
|
+
switch (reason) {
|
|
217
|
+
case 'stop':
|
|
218
|
+
case 'end_turn':
|
|
219
|
+
return { kind: 'stop' }
|
|
220
|
+
case 'tool_calls':
|
|
221
|
+
case 'tool-calls':
|
|
222
|
+
return { kind: 'tool-calls' }
|
|
223
|
+
case 'length':
|
|
224
|
+
case 'max_tokens':
|
|
225
|
+
return { kind: 'max-tokens' }
|
|
226
|
+
default:
|
|
227
|
+
return { kind: 'error', failure: { message: `model stopped: ${reason}`, code: String(reason).toUpperCase() } }
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Map OpenAI usage fields to disjoint harness counts.
|
|
233
|
+
*
|
|
234
|
+
* `prompt_tokens` includes cache hits per the OpenAI convention, and the
|
|
235
|
+
* harness counts are disjoint, so cached tokens are subtracted out of
|
|
236
|
+
* `inputTokens`.
|
|
237
|
+
*/
|
|
238
|
+
export function mapUsage(usage) {
|
|
239
|
+
const cacheRead = usage?.prompt_tokens_details?.cached_tokens ?? usage?.prompt_cache_hit_tokens
|
|
240
|
+
const reasoning = usage?.completion_tokens_details?.reasoning_tokens
|
|
241
|
+
const input = Number.isSafeInteger(usage?.prompt_tokens) ? usage.prompt_tokens : 0
|
|
242
|
+
const output = Number.isSafeInteger(usage?.completion_tokens) ? usage.completion_tokens : 0
|
|
243
|
+
const combined = input + output
|
|
244
|
+
const hasExactTotal = Number.isSafeInteger(usage?.total_tokens) ? usage.total_tokens === combined : true
|
|
245
|
+
return {
|
|
246
|
+
inputTokens: input - (cacheRead ?? 0),
|
|
247
|
+
outputTokens: output,
|
|
248
|
+
...(hasExactTotal ? { totalTokens: combined } : {}),
|
|
249
|
+
...(cacheRead === undefined ? {} : { cacheReadTokens: cacheRead }),
|
|
250
|
+
...(reasoning === undefined ? {} : { reasoningTokens: reasoning }),
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** One streamed identity field: a later empty or null value never clears it. */
|
|
255
|
+
function acceptIdentity(current, incoming) {
|
|
256
|
+
return typeof incoming === 'string' && incoming.length > 0 ? incoming : current
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Read one delta's reasoning text, tolerating both wire spellings.
|
|
261
|
+
*
|
|
262
|
+
* Prefers the string field, and falls back to joining the `reasoning_details`
|
|
263
|
+
* parts, which is where a gateway that omits the flat field puts the same text.
|
|
264
|
+
*
|
|
265
|
+
* @param delta - one OpenAI streaming `delta`.
|
|
266
|
+
* @returns the reasoning fragment, or undefined when this frame carries none.
|
|
267
|
+
*/
|
|
268
|
+
export function reasoningOf(delta) {
|
|
269
|
+
for (const field of ['reasoning', 'reasoning_content']) {
|
|
270
|
+
const value = delta?.[field]
|
|
271
|
+
if (typeof value === 'string' && value.length > 0) return value
|
|
272
|
+
}
|
|
273
|
+
const details = delta?.reasoning_details
|
|
274
|
+
if (Array.isArray(details)) {
|
|
275
|
+
const text = details.map((part) => (typeof part?.text === 'string' ? part.text : '')).join('')
|
|
276
|
+
if (text.length > 0) return text
|
|
277
|
+
}
|
|
278
|
+
return undefined
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Assemble the terminal content block for one open block. */
|
|
282
|
+
function closeBlock(block) {
|
|
283
|
+
switch (block.kind) {
|
|
284
|
+
case 'text':
|
|
285
|
+
return { type: 'text', text: block.text }
|
|
286
|
+
case 'reasoning':
|
|
287
|
+
return { type: 'reasoning', text: block.text }
|
|
288
|
+
default:
|
|
289
|
+
return { type: 'tool-call', id: ToolCallId(block.callId ?? ''), name: block.name ?? '', arguments: block.text }
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Map a gateway error payload to a stable harness code. */
|
|
294
|
+
export function streamErrorCode(message, status) {
|
|
295
|
+
const detail = String(message ?? '')
|
|
296
|
+
if (isQuotaExceededError(detail)) return QUOTA_EXCEEDED_CODE
|
|
297
|
+
if (/unauthorized|re-authenticate|invalid api key|401|403/i.test(detail)) return 'AUTH'
|
|
298
|
+
if (/429|rate limit|rate-?limited/i.test(detail)) return 'RATE_LIMIT'
|
|
299
|
+
if (isContextWindowExceededError(detail)) return CONTEXT_WINDOW_EXCEEDED_CODE
|
|
300
|
+
if (status === 400) return 'INVALID_REQUEST'
|
|
301
|
+
if (status !== undefined && status >= 500) return 'SERVER'
|
|
302
|
+
if (status !== undefined && status >= 400) return `HTTP_${status}`
|
|
303
|
+
return 'UPSTREAM'
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Translate decoded SSE payloads into harness stream chunks.
|
|
308
|
+
*
|
|
309
|
+
* Deltas stream through as they arrive; block ends, usage and the finish reason
|
|
310
|
+
* are all deferred to the `[DONE]` sentinel, so no chunk ever follows `finish`.
|
|
311
|
+
* A completion with no content at all becomes an `EMPTY_RESPONSE` error finish
|
|
312
|
+
* rather than a silent empty message.
|
|
313
|
+
*
|
|
314
|
+
* @param payloads - decoded SSE payloads from {@link parseServerSentEvents}, `[DONE]` last.
|
|
315
|
+
*/
|
|
316
|
+
export async function* translate(payloads) {
|
|
317
|
+
let nextIndex = 0
|
|
318
|
+
let textBlock
|
|
319
|
+
let reasoningBlock
|
|
320
|
+
const toolBlocks = new Map()
|
|
321
|
+
const order = []
|
|
322
|
+
let pendingFinish
|
|
323
|
+
let pendingUsage
|
|
324
|
+
const open = (kind) => {
|
|
325
|
+
const block = { index: nextIndex++, kind, text: '' }
|
|
326
|
+
order.push(block)
|
|
327
|
+
return block
|
|
328
|
+
}
|
|
329
|
+
for await (const payload of payloads) {
|
|
330
|
+
if (payload === '[DONE]') {
|
|
331
|
+
for (const block of order) yield { type: 'block-end', index: block.index, block: closeBlock(block) }
|
|
332
|
+
if (pendingUsage !== undefined) yield { type: 'usage', usage: pendingUsage }
|
|
333
|
+
const reason = pendingFinish ?? { kind: 'stop' }
|
|
334
|
+
yield {
|
|
335
|
+
type: 'finish',
|
|
336
|
+
reason: reason.kind === 'stop' && order.length === 0
|
|
337
|
+
? { kind: 'error', failure: { message: 'model returned a completed response with no content', code: EMPTY_RESPONSE_CODE } }
|
|
338
|
+
: reason,
|
|
339
|
+
}
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
const chunk = unwrapEnvelope(payload)
|
|
343
|
+
// A 200 response whose body is an error object must fail the attempt, so a
|
|
344
|
+
// pin that the router refuses still fails over to the next candidate.
|
|
345
|
+
if (chunk?.error !== undefined && chunk?.choices === undefined) {
|
|
346
|
+
throw new LlmError(errorText(chunk.error), streamErrorCode(errorText(chunk.error)))
|
|
347
|
+
}
|
|
348
|
+
for (const choice of chunk?.choices ?? []) {
|
|
349
|
+
const delta = choice?.delta ?? {}
|
|
350
|
+
// Two spellings carry the same thinking. The Cline gateway streams
|
|
351
|
+
// `reasoning` (plus `reasoning_details`); `reasoning_content` is the
|
|
352
|
+
// DeepSeek-native name some OpenAI-compatible backends use instead.
|
|
353
|
+
// Reading only one silently discarded every reasoning token.
|
|
354
|
+
const reasoning = reasoningOf(delta)
|
|
355
|
+
if (typeof reasoning === 'string' && reasoning.length > 0) {
|
|
356
|
+
if (reasoningBlock === undefined) {
|
|
357
|
+
reasoningBlock = open('reasoning')
|
|
358
|
+
yield { type: 'block-start', index: reasoningBlock.index, blockType: 'reasoning' }
|
|
359
|
+
}
|
|
360
|
+
reasoningBlock.text += reasoning
|
|
361
|
+
yield { type: 'reasoning-delta', index: reasoningBlock.index, text: reasoning }
|
|
362
|
+
}
|
|
363
|
+
const content = delta.content
|
|
364
|
+
if (typeof content === 'string' && content.length > 0) {
|
|
365
|
+
if (textBlock === undefined) {
|
|
366
|
+
textBlock = open('text')
|
|
367
|
+
yield { type: 'block-start', index: textBlock.index, blockType: 'text' }
|
|
368
|
+
}
|
|
369
|
+
textBlock.text += content
|
|
370
|
+
yield { type: 'text-delta', index: textBlock.index, text: content }
|
|
371
|
+
}
|
|
372
|
+
for (const call of delta.tool_calls ?? []) {
|
|
373
|
+
let block = toolBlocks.get(call.index)
|
|
374
|
+
if (block === undefined) {
|
|
375
|
+
block = open('tool-call')
|
|
376
|
+
toolBlocks.set(call.index, block)
|
|
377
|
+
yield { type: 'block-start', index: block.index, blockType: 'tool-call' }
|
|
378
|
+
}
|
|
379
|
+
block.callId = acceptIdentity(block.callId, call.id)
|
|
380
|
+
block.name = acceptIdentity(block.name, call.function?.name)
|
|
381
|
+
const fragment = call.function?.arguments ?? ''
|
|
382
|
+
block.text += fragment
|
|
383
|
+
yield {
|
|
384
|
+
type: 'tool-call-delta',
|
|
385
|
+
index: block.index,
|
|
386
|
+
id: ToolCallId(block.callId ?? ''),
|
|
387
|
+
...(block.name === undefined ? {} : { name: block.name }),
|
|
388
|
+
argumentsDelta: fragment,
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (typeof choice?.finish_reason === 'string') pendingFinish = mapFinishReason(choice.finish_reason)
|
|
392
|
+
}
|
|
393
|
+
if (chunk?.usage !== undefined && chunk.usage !== null) pendingUsage = mapUsage(chunk.usage)
|
|
394
|
+
}
|
|
395
|
+
throw new LlmError('stream ended without [DONE]', 'STREAM_CLOSED')
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
//#endregion
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Watch a payload stream for routing metadata without disturbing it.
|
|
402
|
+
*
|
|
403
|
+
* The gateway attaches `provider_metadata.gateway.routing` to the message of a
|
|
404
|
+
* non-streaming answer; in a stream it appears on whichever frame carries it,
|
|
405
|
+
* so every frame is inspected and the last non-null reading wins.
|
|
406
|
+
*
|
|
407
|
+
* @param payloads - decoded SSE payloads.
|
|
408
|
+
* @param observed - mutable `{ provider, canonical, pipeline }` filled in place.
|
|
409
|
+
*/
|
|
410
|
+
async function* tapRouting(payloads, observed) {
|
|
411
|
+
for await (const payload of payloads) {
|
|
412
|
+
if (payload !== '[DONE]') {
|
|
413
|
+
const routing = parseRouting(payload)
|
|
414
|
+
if (routing.finalProvider !== null) {
|
|
415
|
+
observed.provider = routing.finalProvider
|
|
416
|
+
observed.pipeline = routing.pipeline
|
|
417
|
+
if (routing.canonicalSlug !== null) observed.canonical = routing.canonicalSlug
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
yield payload
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The Cline Pass adapter. One instance serves the plugin's provider route(s);
|
|
426
|
+
* the harness model id IS the gateway's wire model id.
|
|
427
|
+
*/
|
|
428
|
+
export class ClinePassAdapter extends LlmAdapter {
|
|
429
|
+
constructor(config) {
|
|
430
|
+
super()
|
|
431
|
+
this.config = config
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
providerInfo(provider) {
|
|
435
|
+
return { id: provider, name: this.config.connection().displayName }
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
listModels(provider) {
|
|
439
|
+
return Promise.resolve(this.config.connection().models.map((model) => this.modelInfo(provider, model)))
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
resolveModel(provider, model) {
|
|
443
|
+
const connection = this.config.connection()
|
|
444
|
+
const configured = connection.models.find((entry) => entry.id === model)
|
|
445
|
+
const discovered = this.config.discoveredContext?.(model)
|
|
446
|
+
const override = configured ?? { id: model }
|
|
447
|
+
const resolved = resolveModelMetadata(provider, model, override, {
|
|
448
|
+
contextWindow: discovered ?? connection.defaultContextWindow,
|
|
449
|
+
maxTokens: connection.maxTokens,
|
|
450
|
+
reasoning: connection.reasoningModels !== false,
|
|
451
|
+
})
|
|
452
|
+
// A probe that saw this model's real endpoints overrides the published
|
|
453
|
+
// window: it is measured, not documented.
|
|
454
|
+
return Promise.resolve(discovered === undefined
|
|
455
|
+
? resolved
|
|
456
|
+
: { ...resolved, context: { contextWindow: Math.max(discovered, resolved.context.contextWindow) } })
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Advertise one model in the catalog listing. */
|
|
460
|
+
modelInfo(provider, model) {
|
|
461
|
+
const input = Array.isArray(model.input) && model.input.length > 0 ? model.input : ['text']
|
|
462
|
+
return {
|
|
463
|
+
provider,
|
|
464
|
+
id: model.id,
|
|
465
|
+
name: model.name ?? model.id,
|
|
466
|
+
inputModalities: input,
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
stream(options) {
|
|
471
|
+
return this.run(options)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Attempt every pinned candidate in order, yielding the first one that
|
|
476
|
+
* produces content. Failover happens only before the first yielded chunk.
|
|
477
|
+
*/
|
|
478
|
+
async *run(options) {
|
|
479
|
+
const connection = this.config.connection()
|
|
480
|
+
const meta = this.config.modelMeta(options.model) ?? {}
|
|
481
|
+
const attempts = buildAttempts(this.config.pin(options.model))
|
|
482
|
+
// Image bytes are materialized once per call, not once per failover
|
|
483
|
+
// candidate: the candidates differ only in where the request is routed.
|
|
484
|
+
const hasImage = (options.messages ?? []).some((message) => contentHasImage(message.content ?? []))
|
|
485
|
+
const images = await prepareRequestImages(
|
|
486
|
+
options.messages,
|
|
487
|
+
// Only the attachment service is resolved here, and only when the
|
|
488
|
+
// history really carries an image, so a text-only call never touches it.
|
|
489
|
+
hasImage ? this.config.resolveAttachments?.() : undefined,
|
|
490
|
+
this.config.requestImagePolicy ?? DEFAULT_REQUEST_IMAGE_POLICY,
|
|
491
|
+
options.signal,
|
|
492
|
+
)
|
|
493
|
+
const startedAt = Date.now()
|
|
494
|
+
const trace = []
|
|
495
|
+
let delivered = false
|
|
496
|
+
let lastError = null
|
|
497
|
+
for (const attempt of attempts) {
|
|
498
|
+
const account = await this.config.resolveAccount()
|
|
499
|
+
const body = injectPrefs(buildRequestBody(options, connection, images), meta, attempt)
|
|
500
|
+
const attemptStarted = Date.now()
|
|
501
|
+
const consumer = new AbortController()
|
|
502
|
+
const upstream = options.signal === undefined ? consumer.signal : AbortSignal.any([options.signal, consumer.signal])
|
|
503
|
+
const watchdog = idleWatchdog(upstream, connection.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_CODE)
|
|
504
|
+
try {
|
|
505
|
+
let response
|
|
506
|
+
try {
|
|
507
|
+
response = await fetch(chatURL(account.baseURL), {
|
|
508
|
+
method: 'POST',
|
|
509
|
+
headers: {
|
|
510
|
+
authorization: `Bearer ${account.key}`,
|
|
511
|
+
'content-type': 'application/json',
|
|
512
|
+
accept: 'text/event-stream',
|
|
513
|
+
...attributionHeaders(),
|
|
514
|
+
},
|
|
515
|
+
body: JSON.stringify(body),
|
|
516
|
+
signal: watchdog.signal,
|
|
517
|
+
})
|
|
518
|
+
} catch (error) {
|
|
519
|
+
if (options.signal?.aborted) throw new LlmError('Cline Pass request aborted by caller', 'ABORTED', { cause: error })
|
|
520
|
+
const note = errorChain(error)
|
|
521
|
+
trace.push({ upstream: attempt.upstream ?? '(auto)', status: 0, ms: Date.now() - attemptStarted, note })
|
|
522
|
+
this.config.learnUpstream(options.model, attempt.upstream, 'unknown', note, Date.now() - attemptStarted)
|
|
523
|
+
lastError = new LlmError(note, 'TRANSPORT', { cause: error })
|
|
524
|
+
continue
|
|
525
|
+
}
|
|
526
|
+
if (!response.ok) {
|
|
527
|
+
const raw = await response.text().catch(() => '')
|
|
528
|
+
let detail = raw.slice(0, 400)
|
|
529
|
+
try {
|
|
530
|
+
const parsed = unwrapEnvelope(JSON.parse(raw))
|
|
531
|
+
detail = errorText(parsed?.error) || detail
|
|
532
|
+
} catch { /* keep the raw text */ }
|
|
533
|
+
const ms = Date.now() - attemptStarted
|
|
534
|
+
trace.push({ upstream: attempt.upstream ?? '(auto)', status: response.status, ms, note: detail.slice(0, 160) })
|
|
535
|
+
this.config.learnUpstream(options.model, attempt.upstream, classifyUpstreamError(detail), detail, ms)
|
|
536
|
+
lastError = new LlmError(`${detail || `HTTP ${response.status}`} [model=${options.model}]`, streamErrorCode(detail, response.status), { status: response.status })
|
|
537
|
+
// A rejected pin or a busy upstream is worth another candidate; an
|
|
538
|
+
// auth failure is not, so it is raised immediately.
|
|
539
|
+
if (lastError.code === 'AUTH' || lastError.code === QUOTA_EXCEEDED_CODE) throw lastError
|
|
540
|
+
continue
|
|
541
|
+
}
|
|
542
|
+
if (response.body === null || response.body === undefined) {
|
|
543
|
+
lastError = new LlmError('gateway returned no response body', 'EMPTY_RESPONSE', { status: response.status })
|
|
544
|
+
continue
|
|
545
|
+
}
|
|
546
|
+
let yielded = false
|
|
547
|
+
const observed = { provider: null, canonical: null, pipeline: null }
|
|
548
|
+
const iterator = translate(tapRouting(parseServerSentEvents(response.body), observed))[Symbol.asyncIterator]()
|
|
549
|
+
try {
|
|
550
|
+
for (;;) {
|
|
551
|
+
const next = await watchdog.next(iterator)
|
|
552
|
+
if (next.done === true) break
|
|
553
|
+
yielded = true
|
|
554
|
+
yield next.value
|
|
555
|
+
}
|
|
556
|
+
} catch (error) {
|
|
557
|
+
if (timeoutOf(watchdog.signal, STREAM_IDLE_TIMEOUT_CODE) !== undefined) {
|
|
558
|
+
throw new LlmError(`Cline Pass stream idle timeout after ${connection.streamIdleTimeoutMs}ms [model=${options.model}]`, 'TIMEOUT', { cause: error })
|
|
559
|
+
}
|
|
560
|
+
if (options.signal?.aborted) throw new LlmError('Cline Pass request aborted by caller', 'ABORTED', { cause: error })
|
|
561
|
+
const note = error instanceof LlmError ? error.message : errorChain(error)
|
|
562
|
+
const ms = Date.now() - attemptStarted
|
|
563
|
+
trace.push({ upstream: attempt.upstream ?? '(auto)', status: 200, ms, note: note.slice(0, 160) })
|
|
564
|
+
this.config.learnUpstream(options.model, attempt.upstream, classifyUpstreamError(note), note, ms)
|
|
565
|
+
// Content already reached the caller: this is the answer's failure,
|
|
566
|
+
// not a reason to silently ask another channel. A missing account is
|
|
567
|
+
// fatal for the same reason — every candidate would fail the same way.
|
|
568
|
+
if (yielded || error?.code === 'AUTH' || error?.code === QUOTA_EXCEEDED_CODE) {
|
|
569
|
+
this.config.record(options.model, {
|
|
570
|
+
provider: observed.provider,
|
|
571
|
+
canonical: observed.canonical,
|
|
572
|
+
attempts: trace.map((row) => row.upstream),
|
|
573
|
+
ms: Date.now() - startedAt,
|
|
574
|
+
stream: true,
|
|
575
|
+
error: note.slice(0, 200),
|
|
576
|
+
account: account.name,
|
|
577
|
+
})
|
|
578
|
+
throw error instanceof LlmError ? error : new LlmError(note, 'TRANSPORT', { cause: error })
|
|
579
|
+
}
|
|
580
|
+
lastError = error instanceof LlmError ? error : new LlmError(note, 'TRANSPORT', { cause: error })
|
|
581
|
+
continue
|
|
582
|
+
} finally {
|
|
583
|
+
consumer.abort('Cline Pass stream consumer stopped')
|
|
584
|
+
if (iterator.return !== undefined) {
|
|
585
|
+
try {
|
|
586
|
+
await iterator.return()
|
|
587
|
+
} catch { /* the consumer controller already owns termination */ }
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
delivered = true
|
|
591
|
+
trace.push({ upstream: attempt.upstream ?? '(auto)', status: 200, ms: Date.now() - attemptStarted, note: 'ok' })
|
|
592
|
+
this.config.record(options.model, {
|
|
593
|
+
provider: observed.provider ?? (attempt.upstream ?? null),
|
|
594
|
+
canonical: observed.canonical,
|
|
595
|
+
attempts: trace.map((row) => row.upstream),
|
|
596
|
+
ms: Date.now() - startedAt,
|
|
597
|
+
stream: true,
|
|
598
|
+
error: null,
|
|
599
|
+
account: account.name,
|
|
600
|
+
})
|
|
601
|
+
if (attempt.upstream !== null) this.config.learnUpstream(options.model, attempt.upstream, 'ok', '', Date.now() - attemptStarted)
|
|
602
|
+
return
|
|
603
|
+
} finally {
|
|
604
|
+
watchdog[Symbol.dispose]()
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
if (!delivered) {
|
|
608
|
+
const message = lastError?.message ?? 'no pinned upstream could serve the request'
|
|
609
|
+
this.config.record(options.model, {
|
|
610
|
+
provider: null,
|
|
611
|
+
attempts: trace.map((row) => row.upstream),
|
|
612
|
+
ms: Date.now() - startedAt,
|
|
613
|
+
stream: true,
|
|
614
|
+
error: message.slice(0, 200),
|
|
615
|
+
account: null,
|
|
616
|
+
})
|
|
617
|
+
throw lastError ?? new LlmError(`${message} [model=${options.model}]`, 'UPSTREAM')
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|