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 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-0.1.0.tgz
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-0.1.0.tgz
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) wire.push({ role: 'user', content: parts })
171
- else if (text.length > 0 || toolResults.length === 0) wire.push({ role: 'user', content: text })
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; an
538
- // auth failure is not, so it is raised immediately.
539
- if (lastError.code === 'AUTH' || lastError.code === QUOTA_EXCEEDED_CODE) throw lastError
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
- const iterator = translate(tapRouting(parseServerSentEvents(response.body), observed))[Symbol.asyncIterator]()
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
- // 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) {
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
- trace.push({ upstream: attempt.upstream ?? '(auto)', status: 200, ms: Date.now() - attemptStarted, note: 'ok' })
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
- if (attempt.upstream !== null) this.config.learnUpstream(options.model, attempt.upstream, 'ok', '', Date.now() - attemptStarted)
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 published catalog,
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 {