dsh-cline-pass 0.1.0 → 0.2.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/README.en.md +1 -1
- package/README.md +5 -1
- package/lib/adapter.js +270 -22
- package/lib/catalog.js +13 -5
- package/lib/client.js +1026 -190
- package/lib/cline.js +170 -5
- package/lib/compat.js +154 -0
- package/lib/engine.js +153 -28
- package/lib/index.js +172 -27
- package/lib/panel.js +162 -85
- package/lib/protocol.js +215 -25
- package/lib/store.js +17 -2
- package/lib/tools.js +155 -14
- package/package.json +3 -2
package/README.en.md
CHANGED
|
@@ -27,7 +27,7 @@ You can also install a local directory or tarball:
|
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
29
|
dsh plugin --profile web add /path/to/dsh-cline-pass
|
|
30
|
-
dsh plugin --profile web add ./dsh-cline-pass
|
|
30
|
+
dsh plugin --profile web add ./dsh-cline-pass-<version>.tgz
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
Restart dsh after installation.
|
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@ dsh plugin --profile web add dsh-cline-pass
|
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
29
|
dsh plugin --profile web add /path/to/dsh-cline-pass
|
|
30
|
-
dsh plugin --profile web add ./dsh-cline-pass
|
|
30
|
+
dsh plugin --profile web add ./dsh-cline-pass-<version>.tgz
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
安装后重启 dsh。
|
|
@@ -119,6 +119,10 @@ npm run test:live:reasoning
|
|
|
119
119
|
|
|
120
120
|
上游渠道和故障转移行为参考了 MIT 许可的 [`cline-pass-switcher`](https://github.com/munmunjaklin458-afk/cline-pass-switcher)。
|
|
121
121
|
|
|
122
|
+
## 友情链接
|
|
123
|
+
|
|
124
|
+
[LinuxDo](https://linux.do/) — 真诚、友善、团结、专业,你的品质开源与技术社区
|
|
125
|
+
|
|
122
126
|
## License
|
|
123
127
|
|
|
124
128
|
[MIT](LICENSE)
|
package/lib/adapter.js
CHANGED
|
@@ -30,6 +30,8 @@ import {
|
|
|
30
30
|
errorText,
|
|
31
31
|
injectPrefs,
|
|
32
32
|
parseRouting,
|
|
33
|
+
pinAdherence,
|
|
34
|
+
pinWarnings,
|
|
33
35
|
unwrapEnvelope,
|
|
34
36
|
} from './protocol.js'
|
|
35
37
|
import { parseServerSentEvents } from './engine.js'
|
|
@@ -56,6 +58,84 @@ export const DEFAULT_MAX_TOKENS = 32000
|
|
|
56
58
|
* cap. These match the values the shipped adapters use.
|
|
57
59
|
*/
|
|
58
60
|
export const DEFAULT_REQUEST_IMAGE_POLICY = { maxPixels: 4194304, maxBytes: 1048576 }
|
|
61
|
+
/**
|
|
62
|
+
* Lead-in text for the user message that carries images recovered from tool
|
|
63
|
+
* results, matching the wording the shipped DeepSeek adapter uses.
|
|
64
|
+
*/
|
|
65
|
+
const TOOL_RESULT_IMAGE_TEXT = 'Attached image(s) from tool result:'
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Compute aspect-preserving integer dimensions within a total-pixel budget.
|
|
69
|
+
*
|
|
70
|
+
* Local copy of the harness's own projection: it is pure geometry, and this
|
|
71
|
+
* adapter must not add a hard dependency on `@deepseek-ai/dsh-attachment` for
|
|
72
|
+
* one helper. Mirrors `requestImageDimensions`, including the inward rounding
|
|
73
|
+
* and the "never enlarge a small image" rule.
|
|
74
|
+
*
|
|
75
|
+
* @param width - positive source width.
|
|
76
|
+
* @param height - positive source height.
|
|
77
|
+
* @param maxPixels - positive width-times-height cap.
|
|
78
|
+
* @returns projected dimensions.
|
|
79
|
+
*/
|
|
80
|
+
export function projectImageDimensions(width, height, maxPixels) {
|
|
81
|
+
const safeWidth = Number.isSafeInteger(width) && width > 0 ? width : 1
|
|
82
|
+
const safeHeight = Number.isSafeInteger(height) && height > 0 ? height : 1
|
|
83
|
+
const safeBudget = Number.isSafeInteger(maxPixels) && maxPixels > 0 ? maxPixels : DEFAULT_REQUEST_IMAGE_POLICY.maxPixels
|
|
84
|
+
const scale = Math.min(1, Math.sqrt(safeBudget / (safeWidth * safeHeight)))
|
|
85
|
+
if (scale === 1) return { width: safeWidth, height: safeHeight }
|
|
86
|
+
if (safeWidth >= safeHeight) {
|
|
87
|
+
let projectedWidth = Math.max(1, Math.floor(safeWidth * scale))
|
|
88
|
+
let projectedHeight = Math.max(1, Math.round((projectedWidth * safeHeight) / safeWidth))
|
|
89
|
+
while (projectedWidth * projectedHeight > safeBudget && projectedWidth > 1) {
|
|
90
|
+
projectedWidth -= 1
|
|
91
|
+
projectedHeight = Math.max(1, Math.round((projectedWidth * safeHeight) / safeWidth))
|
|
92
|
+
}
|
|
93
|
+
return { width: projectedWidth, height: projectedHeight }
|
|
94
|
+
}
|
|
95
|
+
let projectedHeight = Math.max(1, Math.floor(safeHeight * scale))
|
|
96
|
+
let projectedWidth = Math.max(1, Math.round((projectedHeight * safeWidth) / safeHeight))
|
|
97
|
+
while (projectedWidth * projectedHeight > safeBudget && projectedHeight > 1) {
|
|
98
|
+
projectedHeight -= 1
|
|
99
|
+
projectedWidth = Math.max(1, Math.round((projectedHeight * safeWidth) / safeHeight))
|
|
100
|
+
}
|
|
101
|
+
return { width: projectedWidth, height: projectedHeight }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Build the target object one attachment must be materialized at.
|
|
106
|
+
*
|
|
107
|
+
* The harness changed this contract between releases and the two shapes are
|
|
108
|
+
* mutually exclusive, not additive:
|
|
109
|
+
*
|
|
110
|
+
* - 0.1.2 - 0.1.5 validate `{maxPixels, maxBytes}` and project the dimensions
|
|
111
|
+
* themselves;
|
|
112
|
+
* - 0.1.6+ validate `{width, height, maxBytes}` and take the dimensions as
|
|
113
|
+
* given.
|
|
114
|
+
*
|
|
115
|
+
* A caller that sends only one shape fails on the other host — the newer one
|
|
116
|
+
* rejects a missing `width` with `Image request width must be a positive
|
|
117
|
+
* integer.`, which surfaces to the user as a failed turn on every image. Each
|
|
118
|
+
* validator reads only the fields it knows and ignores the rest, so one target
|
|
119
|
+
* carrying both the projected dimensions and the pixel budget satisfies either
|
|
120
|
+
* host. The dimensions are projected here with the same geometry the older
|
|
121
|
+
* host would have applied, so the rendered result is identical on both.
|
|
122
|
+
*
|
|
123
|
+
* @param ref - the durable attachment reference, which carries its own size.
|
|
124
|
+
* @param policy - the configured pixel and byte budget.
|
|
125
|
+
* @returns the request target for `readImageRequest`.
|
|
126
|
+
* @throws {LlmError} UNSUPPORTED_CONTENT when the reference carries no usable size.
|
|
127
|
+
*/
|
|
128
|
+
export function requestImageTarget(ref, policy) {
|
|
129
|
+
const maxPixels = policy?.maxPixels ?? DEFAULT_REQUEST_IMAGE_POLICY.maxPixels
|
|
130
|
+
const maxBytes = policy?.maxBytes ?? DEFAULT_REQUEST_IMAGE_POLICY.maxBytes
|
|
131
|
+
const width = ref?.width
|
|
132
|
+
const height = ref?.height
|
|
133
|
+
if (!Number.isSafeInteger(width) || width <= 0 || !Number.isSafeInteger(height) || height <= 0) {
|
|
134
|
+
throw new LlmError('The Cline Pass adapter received an image reference without usable dimensions.', 'UNSUPPORTED_CONTENT')
|
|
135
|
+
}
|
|
136
|
+
const projected = projectImageDimensions(width, height, maxPixels)
|
|
137
|
+
return { width: projected.width, height: projected.height, maxPixels, maxBytes }
|
|
138
|
+
}
|
|
59
139
|
|
|
60
140
|
const STREAM_IDLE_TIMEOUT_CODE = 'CLINE_PASS_STREAM_IDLE_TIMEOUT'
|
|
61
141
|
|
|
@@ -107,7 +187,7 @@ export async function prepareRequestImages(messages, attachments, policy, signal
|
|
|
107
187
|
throw new LlmError('The Cline Pass adapter needs the durable attachment service to send image input.', 'UNSUPPORTED_CONTENT')
|
|
108
188
|
}
|
|
109
189
|
const ordered = [...refs.values()]
|
|
110
|
-
const prepared = await Promise.all(ordered.map((ref) => attachments.readImageRequest(ref, policy, signal)))
|
|
190
|
+
const prepared = await Promise.all(ordered.map((ref) => attachments.readImageRequest(ref, requestImageTarget(ref, policy), signal)))
|
|
111
191
|
const versions = new Map()
|
|
112
192
|
for (const [index, ref] of ordered.entries()) versions.set(ref.attachmentId, prepared[index])
|
|
113
193
|
return versions
|
|
@@ -135,21 +215,55 @@ function serializeAssistant(message) {
|
|
|
135
215
|
}
|
|
136
216
|
}
|
|
137
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Resolve one image block into its OpenAI wire part.
|
|
220
|
+
*
|
|
221
|
+
* @throws {LlmError} UNSUPPORTED_CONTENT when its request bytes were not prepared.
|
|
222
|
+
*/
|
|
223
|
+
function imagePart(block, images) {
|
|
224
|
+
const version = images?.get(block.attachment?.attachmentId)
|
|
225
|
+
if (version === undefined) {
|
|
226
|
+
throw new LlmError('The Cline Pass adapter received an image it could not resolve into request bytes.', 'UNSUPPORTED_CONTENT')
|
|
227
|
+
}
|
|
228
|
+
return { type: 'image_url', image_url: { url: `data:${version.mediaType};base64,${toBase64(version.data)}` } }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Recursively collect image parts from blocks, including nested tool results. */
|
|
232
|
+
function collectImageParts(blocks, images, target = []) {
|
|
233
|
+
for (const block of blocks ?? []) {
|
|
234
|
+
if (block.type === 'image') target.push(imagePart(block, images))
|
|
235
|
+
else if (block.type === 'tool-result') collectImageParts(block.content, images, target)
|
|
236
|
+
}
|
|
237
|
+
return target
|
|
238
|
+
}
|
|
239
|
+
|
|
138
240
|
/**
|
|
139
241
|
* Convert the harness conversation to wire messages.
|
|
140
242
|
*
|
|
141
243
|
* Tool results become standalone `{ role: 'tool' }` messages. A user message
|
|
142
244
|
* carrying an image switches to the OpenAI content-part array; a text-only one
|
|
143
245
|
* keeps the plain-string form.
|
|
246
|
+
*
|
|
247
|
+
* An image nested in a tool result (read_image, a screenshot tool) has no
|
|
248
|
+
* OpenAI `tool` wire spelling, so it rides a following user message instead —
|
|
249
|
+
* the same shape the shipped adapters use.
|
|
144
250
|
*/
|
|
145
251
|
function serializeMessages(messages, images) {
|
|
146
252
|
const wire = []
|
|
253
|
+
let pendingToolImages = []
|
|
254
|
+
const flushToolImages = () => {
|
|
255
|
+
if (pendingToolImages.length === 0) return
|
|
256
|
+
wire.push({ role: 'user', content: [{ type: 'text', text: TOOL_RESULT_IMAGE_TEXT }, ...pendingToolImages] })
|
|
257
|
+
pendingToolImages = []
|
|
258
|
+
}
|
|
147
259
|
for (const message of messages) {
|
|
148
260
|
if (message.role === 'system') {
|
|
261
|
+
flushToolImages()
|
|
149
262
|
wire.push({ role: 'system', content: flattenText(message.content) })
|
|
150
263
|
continue
|
|
151
264
|
}
|
|
152
265
|
if (message.role === 'assistant') {
|
|
266
|
+
flushToolImages()
|
|
153
267
|
wire.push(serializeAssistant(message))
|
|
154
268
|
continue
|
|
155
269
|
}
|
|
@@ -157,26 +271,24 @@ function serializeMessages(messages, images) {
|
|
|
157
271
|
const parts = []
|
|
158
272
|
for (const block of message.content ?? []) {
|
|
159
273
|
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
|
-
}
|
|
274
|
+
else if (block.type === 'image') parts.push(imagePart(block, images))
|
|
167
275
|
}
|
|
168
276
|
const hasImage = parts.some((part) => part.type === 'image_url')
|
|
169
277
|
const text = parts.filter((part) => part.type === 'text').map((part) => part.text).join('')
|
|
170
|
-
if (hasImage
|
|
171
|
-
|
|
278
|
+
if (hasImage || text.length > 0 || toolResults.length === 0) {
|
|
279
|
+
flushToolImages()
|
|
280
|
+
wire.push({ role: 'user', content: hasImage ? parts : text })
|
|
281
|
+
}
|
|
172
282
|
for (const result of toolResults) {
|
|
173
283
|
wire.push({
|
|
174
284
|
role: 'tool',
|
|
175
285
|
tool_call_id: result.toolCallId,
|
|
176
286
|
content: flattenText(result.content) || '(no output)',
|
|
177
287
|
})
|
|
288
|
+
collectImageParts(result.content, images, pendingToolImages)
|
|
178
289
|
}
|
|
179
290
|
}
|
|
291
|
+
flushToolImages()
|
|
180
292
|
return wire
|
|
181
293
|
}
|
|
182
294
|
|
|
@@ -303,6 +415,31 @@ export function streamErrorCode(message, status) {
|
|
|
303
415
|
return 'UPSTREAM'
|
|
304
416
|
}
|
|
305
417
|
|
|
418
|
+
/**
|
|
419
|
+
* Note the moment the first byte of a response body arrives.
|
|
420
|
+
*
|
|
421
|
+
* A pass-through stream: it reports once, on the first chunk, and forwards every
|
|
422
|
+
* chunk unchanged. A keep-alive comment or an early frame reaches this before it
|
|
423
|
+
* reaches the SSE parser, so the recorded moment is the gateway's first sign of
|
|
424
|
+
* life rather than the caller's first content.
|
|
425
|
+
*
|
|
426
|
+
* @param body - the response body stream.
|
|
427
|
+
* @param onFirst - called once, when the first chunk is seen.
|
|
428
|
+
* @returns a stream equivalent to `body`.
|
|
429
|
+
*/
|
|
430
|
+
function tapFirstByte(body, onFirst) {
|
|
431
|
+
let seen = false
|
|
432
|
+
return body.pipeThrough(new TransformStream({
|
|
433
|
+
transform(chunk, controller) {
|
|
434
|
+
if (!seen) {
|
|
435
|
+
seen = true
|
|
436
|
+
onFirst()
|
|
437
|
+
}
|
|
438
|
+
controller.enqueue(chunk)
|
|
439
|
+
},
|
|
440
|
+
}))
|
|
441
|
+
}
|
|
442
|
+
|
|
306
443
|
/**
|
|
307
444
|
* Translate decoded SSE payloads into harness stream chunks.
|
|
308
445
|
*
|
|
@@ -444,11 +581,15 @@ export class ClinePassAdapter extends LlmAdapter {
|
|
|
444
581
|
const configured = connection.models.find((entry) => entry.id === model)
|
|
445
582
|
const discovered = this.config.discoveredContext?.(model)
|
|
446
583
|
const override = configured ?? { id: model }
|
|
584
|
+
// What the last official scan published about this model. Without it a model
|
|
585
|
+
// newer than this release resolves to the `text` fallback and the harness
|
|
586
|
+
// refuses every image for it, silently.
|
|
587
|
+
const published = this.config.publishedModel?.(model) ?? {}
|
|
447
588
|
const resolved = resolveModelMetadata(provider, model, override, {
|
|
448
589
|
contextWindow: discovered ?? connection.defaultContextWindow,
|
|
449
590
|
maxTokens: connection.maxTokens,
|
|
450
591
|
reasoning: connection.reasoningModels !== false,
|
|
451
|
-
})
|
|
592
|
+
}, published)
|
|
452
593
|
// A probe that saw this model's real endpoints overrides the published
|
|
453
594
|
// window: it is measured, not documented.
|
|
454
595
|
return Promise.resolve(discovered === undefined
|
|
@@ -494,8 +635,38 @@ export class ClinePassAdapter extends LlmAdapter {
|
|
|
494
635
|
const trace = []
|
|
495
636
|
let delivered = false
|
|
496
637
|
let lastError = null
|
|
638
|
+
/**
|
|
639
|
+
* When the caller saw its first chunk, in ms from the request's own start.
|
|
640
|
+
*
|
|
641
|
+
* Measured from `startedAt` rather than from the serving attempt so it can be
|
|
642
|
+
* read next to `ms`, which is also request-scoped: a failover attempt's dead
|
|
643
|
+
* time is part of what the user actually waited through. Zero means no chunk
|
|
644
|
+
* ever arrived, which is a fact worth keeping rather than a missing value.
|
|
645
|
+
*/
|
|
646
|
+
let firstChunkMs = 0
|
|
647
|
+
/**
|
|
648
|
+
* When the gateway's first byte arrived, in ms from the request's own start.
|
|
649
|
+
*
|
|
650
|
+
* `ttft` answers "when did the caller see something"; this answers "when did
|
|
651
|
+
* the gateway start talking at all". The pair separates two very different
|
|
652
|
+
* stalls: a gateway that accepted the request and then sent nothing (both
|
|
653
|
+
* late) from one that keeps the socket warm while the model thinks — a
|
|
654
|
+
* keep-alive comment or an early frame produces bytes without producing a
|
|
655
|
+
* chunk, and only having both tells those apart.
|
|
656
|
+
*/
|
|
657
|
+
let firstByteMs = 0
|
|
658
|
+
/**
|
|
659
|
+
* Accounts this request has already been refused by.
|
|
660
|
+
*
|
|
661
|
+
* The pool is offered as one account with a combined quota, so a refusal that
|
|
662
|
+
* is about the ACCOUNT — a spent window, a revoked key — moves to the next
|
|
663
|
+
* one instead of ending the request. `resolveAccount` answers `undefined` once
|
|
664
|
+
* nothing is left, which is when the real error is raised below.
|
|
665
|
+
*/
|
|
666
|
+
const refused = new Set()
|
|
497
667
|
for (const attempt of attempts) {
|
|
498
|
-
const account = await this.config.resolveAccount()
|
|
668
|
+
const account = await this.config.resolveAccount({ exclude: [...refused] })
|
|
669
|
+
if (account === undefined) break
|
|
499
670
|
const body = injectPrefs(buildRequestBody(options, connection, images), meta, attempt)
|
|
500
671
|
const attemptStarted = Date.now()
|
|
501
672
|
const consumer = new AbortController()
|
|
@@ -534,9 +705,22 @@ export class ClinePassAdapter extends LlmAdapter {
|
|
|
534
705
|
trace.push({ upstream: attempt.upstream ?? '(auto)', status: response.status, ms, note: detail.slice(0, 160) })
|
|
535
706
|
this.config.learnUpstream(options.model, attempt.upstream, classifyUpstreamError(detail), detail, ms)
|
|
536
707
|
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
|
|
538
|
-
//
|
|
539
|
-
|
|
708
|
+
// A rejected pin or a busy upstream is worth another candidate. An
|
|
709
|
+
// account-level refusal is worth another ACCOUNT: that is the whole
|
|
710
|
+
// point of a pool, and with one account the next attempt simply finds
|
|
711
|
+
// nothing left and this error is raised unchanged.
|
|
712
|
+
if (lastError.code === 'AUTH' || lastError.code === QUOTA_EXCEEDED_CODE) {
|
|
713
|
+
refused.add(account.name)
|
|
714
|
+
this.config.record(options.model, {
|
|
715
|
+
provider: null,
|
|
716
|
+
attempts: trace.map((row) => row.upstream),
|
|
717
|
+
ms: Date.now() - startedAt,
|
|
718
|
+
stream: true,
|
|
719
|
+
error: firstLine(lastError.message, 200),
|
|
720
|
+
code: lastError.code,
|
|
721
|
+
account: account.name,
|
|
722
|
+
})
|
|
723
|
+
}
|
|
540
724
|
continue
|
|
541
725
|
}
|
|
542
726
|
if (response.body === null || response.body === undefined) {
|
|
@@ -545,11 +729,28 @@ export class ClinePassAdapter extends LlmAdapter {
|
|
|
545
729
|
}
|
|
546
730
|
let yielded = false
|
|
547
731
|
const observed = { provider: null, canonical: null, pipeline: null }
|
|
548
|
-
|
|
732
|
+
// `body` in this scope is the request payload, so the response stream
|
|
733
|
+
// gets its own name.
|
|
734
|
+
const responseBody = tapFirstByte(response.body, () => {
|
|
735
|
+
if (firstByteMs === 0) firstByteMs = Date.now() - startedAt
|
|
736
|
+
})
|
|
737
|
+
const iterator = translate(tapRouting(parseServerSentEvents(responseBody), observed))[Symbol.asyncIterator]()
|
|
738
|
+
/**
|
|
739
|
+
* The usage frame, captured as it passes through.
|
|
740
|
+
*
|
|
741
|
+
* The gateway sends it in the last frames, after the content, so a record
|
|
742
|
+
* written when the stream ends has it — but only if the chunk was read on
|
|
743
|
+
* its way to the caller. `reasoningTokens` in particular is the number
|
|
744
|
+
* that explains a large time-to-first-chunk: it is thinking the caller
|
|
745
|
+
* cannot see, because this gateway does not stream it.
|
|
746
|
+
*/
|
|
747
|
+
let usage = null
|
|
549
748
|
try {
|
|
550
749
|
for (;;) {
|
|
551
750
|
const next = await watchdog.next(iterator)
|
|
552
751
|
if (next.done === true) break
|
|
752
|
+
if (next.value?.type === 'usage') usage = next.value.usage
|
|
753
|
+
if (firstChunkMs === 0) firstChunkMs = Date.now() - startedAt
|
|
553
754
|
yielded = true
|
|
554
755
|
yield next.value
|
|
555
756
|
}
|
|
@@ -562,21 +763,41 @@ export class ClinePassAdapter extends LlmAdapter {
|
|
|
562
763
|
const ms = Date.now() - attemptStarted
|
|
563
764
|
trace.push({ upstream: attempt.upstream ?? '(auto)', status: 200, ms, note: note.slice(0, 160) })
|
|
564
765
|
this.config.learnUpstream(options.model, attempt.upstream, classifyUpstreamError(note), note, ms)
|
|
565
|
-
// Content already reached the caller: this is the answer's failure,
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
|
|
766
|
+
// Content already reached the caller: this is the answer's failure, and
|
|
767
|
+
// no retry can un-deliver it. Before any content, an account-level
|
|
768
|
+
// refusal (a spent window, a revoked key) moves to the next account —
|
|
769
|
+
// the pool exists to absorb exactly that.
|
|
770
|
+
const code = error instanceof LlmError ? error.code : 'TRANSPORT'
|
|
771
|
+
if (yielded) {
|
|
569
772
|
this.config.record(options.model, {
|
|
570
773
|
provider: observed.provider,
|
|
571
774
|
canonical: observed.canonical,
|
|
572
775
|
attempts: trace.map((row) => row.upstream),
|
|
573
776
|
ms: Date.now() - startedAt,
|
|
777
|
+
ttfb: firstByteMs,
|
|
778
|
+
ttft: firstChunkMs,
|
|
574
779
|
stream: true,
|
|
575
780
|
error: note.slice(0, 200),
|
|
781
|
+
code,
|
|
576
782
|
account: account.name,
|
|
577
783
|
})
|
|
578
784
|
throw error instanceof LlmError ? error : new LlmError(note, 'TRANSPORT', { cause: error })
|
|
579
785
|
}
|
|
786
|
+
if (code === 'AUTH' || code === QUOTA_EXCEEDED_CODE) {
|
|
787
|
+
refused.add(account.name)
|
|
788
|
+
this.config.record(options.model, {
|
|
789
|
+
provider: observed.provider,
|
|
790
|
+
canonical: observed.canonical,
|
|
791
|
+
attempts: trace.map((row) => row.upstream),
|
|
792
|
+
ms: Date.now() - startedAt,
|
|
793
|
+
ttfb: firstByteMs,
|
|
794
|
+
ttft: firstChunkMs,
|
|
795
|
+
stream: true,
|
|
796
|
+
error: note.slice(0, 200),
|
|
797
|
+
code,
|
|
798
|
+
account: account.name,
|
|
799
|
+
})
|
|
800
|
+
}
|
|
580
801
|
lastError = error instanceof LlmError ? error : new LlmError(note, 'TRANSPORT', { cause: error })
|
|
581
802
|
continue
|
|
582
803
|
} finally {
|
|
@@ -588,17 +809,42 @@ export class ClinePassAdapter extends LlmAdapter {
|
|
|
588
809
|
}
|
|
589
810
|
}
|
|
590
811
|
delivered = true
|
|
591
|
-
|
|
812
|
+
// Which channel answered is read back from the stream, because a 200
|
|
813
|
+
// only proves someone answered. The router drops an unusable `only`
|
|
814
|
+
// without complaining, so "we asked for baseten" and "baseten served it"
|
|
815
|
+
// are different facts and only the second one is evidence.
|
|
816
|
+
const adherence = pinAdherence(attempt, { finalProvider: observed.provider })
|
|
817
|
+
const warnings = pinWarnings(attempt, meta)
|
|
818
|
+
trace.push({
|
|
819
|
+
upstream: attempt.upstream ?? '(auto)',
|
|
820
|
+
status: 200,
|
|
821
|
+
ms: Date.now() - attemptStarted,
|
|
822
|
+
note: adherence === 'not-adopted' || adherence === 'violated'
|
|
823
|
+
? `pin not adopted: served by ${observed.provider ?? '(unknown)'}`
|
|
824
|
+
: 'ok',
|
|
825
|
+
})
|
|
592
826
|
this.config.record(options.model, {
|
|
593
827
|
provider: observed.provider ?? (attempt.upstream ?? null),
|
|
594
828
|
canonical: observed.canonical,
|
|
595
829
|
attempts: trace.map((row) => row.upstream),
|
|
596
830
|
ms: Date.now() - startedAt,
|
|
831
|
+
ttfb: firstByteMs,
|
|
832
|
+
ttft: firstChunkMs,
|
|
597
833
|
stream: true,
|
|
598
834
|
error: null,
|
|
599
835
|
account: account.name,
|
|
836
|
+
adherence,
|
|
837
|
+
warnings,
|
|
838
|
+
// What the request cost in tokens, and how much of that the model spent
|
|
839
|
+
// thinking invisibly — the two numbers that explain a large `ttft`.
|
|
840
|
+
usage,
|
|
841
|
+
effort: options.reasoningEffort ?? '',
|
|
600
842
|
})
|
|
601
|
-
|
|
843
|
+
// Only a pin the router actually honored proves the channel works.
|
|
844
|
+
// Marking it usable no matter who answered is how a false "available"
|
|
845
|
+
// reached the verdict map the exclusion allow-list is built from — the
|
|
846
|
+
// map then vouched for the very channels it was meant to exclude.
|
|
847
|
+
if (attempt.upstream !== null && adherence === 'adopted') this.config.learnUpstream(options.model, attempt.upstream, 'ok', '', Date.now() - attemptStarted)
|
|
602
848
|
return
|
|
603
849
|
} finally {
|
|
604
850
|
watchdog[Symbol.dispose]()
|
|
@@ -610,6 +856,8 @@ export class ClinePassAdapter extends LlmAdapter {
|
|
|
610
856
|
provider: null,
|
|
611
857
|
attempts: trace.map((row) => row.upstream),
|
|
612
858
|
ms: Date.now() - startedAt,
|
|
859
|
+
ttfb: firstByteMs,
|
|
860
|
+
ttft: firstChunkMs,
|
|
613
861
|
stream: true,
|
|
614
862
|
error: message.slice(0, 200),
|
|
615
863
|
account: null,
|
package/lib/catalog.js
CHANGED
|
@@ -168,17 +168,25 @@ const SEAM_MODALITIES = new Set(['text', 'image'])
|
|
|
168
168
|
/**
|
|
169
169
|
* Resolve the metadata seam fields for one model.
|
|
170
170
|
*
|
|
171
|
-
* Precedence per field: the configured override, then the
|
|
172
|
-
* then the route-wide fallback.
|
|
171
|
+
* Precedence per field: the configured override, then the shipped catalog, then
|
|
172
|
+
* what the last official scan published, then the route-wide fallback.
|
|
173
173
|
*
|
|
174
|
+
* The live scan sits below the shipped table rather than above it because the
|
|
175
|
+
* shipped entry is curated: models.dev reports `audio`/`video`/`pdf`, and the
|
|
176
|
+
* underscore-free spelling of a display name, neither of which the seam or the
|
|
177
|
+
* provider's own label wants. It exists to cover what the table cannot know —
|
|
178
|
+
* a model published after this release — so a multimodal model is never adopted
|
|
179
|
+
* as text-only just because it is new.
|
|
180
|
+
*
|
|
181
|
+
* @param discovered - metadata for this model from the last catalog scan.
|
|
174
182
|
* @returns a descriptor the seam's `normalizeModelInfo` accepts.
|
|
175
183
|
*/
|
|
176
|
-
export function resolveModelMetadata(provider, model, override, fallback) {
|
|
177
|
-
const published = catalogEntry(model) ?? {}
|
|
184
|
+
export function resolveModelMetadata(provider, model, override, fallback, discovered = {}) {
|
|
185
|
+
const published = { ...(discovered ?? {}), ...(catalogEntry(model) ?? {}) }
|
|
178
186
|
const name = pick(override?.name, published.name, model)
|
|
179
187
|
const contextWindow = pick(override?.contextWindow, published.contextWindow, fallback.contextWindow)
|
|
180
188
|
const maxTokens = pick(override?.maxTokens, published.maxTokens, fallback.maxTokens)
|
|
181
|
-
const modality = pick(override?.input, published.input, undefined)
|
|
189
|
+
const modality = pick(override?.input, published.input ?? published.inputModalities, undefined)
|
|
182
190
|
const reasoning = override?.reasoning ?? published.reasoning ?? fallback.reasoning
|
|
183
191
|
const offered = (modality ?? ['text']).filter((entry) => SEAM_MODALITIES.has(entry))
|
|
184
192
|
return {
|