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/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
+ }