@anionex/dsh-vision-toolkit 0.1.7 → 0.1.9

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.
Files changed (51) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +34 -17
  3. package/README.zh.md +32 -7
  4. package/assets/community-group-qr.png +0 -0
  5. package/assets/vision-model-test.png +0 -0
  6. package/docs/requirements-traceability/README.i18n.yaml +2 -2
  7. package/docs/requirements-traceability/README.md +2 -2
  8. package/docs/requirements-traceability/README.zh.md +2 -2
  9. package/lib/client.js +361 -28
  10. package/lib/client.js.map +1 -1
  11. package/lib/config.js +14 -0
  12. package/lib/config.js.map +1 -1
  13. package/lib/image-input-variants.js +615 -0
  14. package/lib/image-input-variants.js.map +1 -0
  15. package/lib/index.js +8 -1
  16. package/lib/index.js.map +1 -1
  17. package/lib/paste-images.js +6 -0
  18. package/lib/paste-images.js.map +1 -1
  19. package/lib/runtime.js +37 -3
  20. package/lib/runtime.js.map +1 -1
  21. package/lib/types/client/index.d.ts +16 -5
  22. package/lib/types/client/index.d.ts.map +1 -1
  23. package/lib/types/client/paste-images.d.ts +69 -0
  24. package/lib/types/client/paste-images.d.ts.map +1 -1
  25. package/lib/types/config.d.ts +26 -0
  26. package/lib/types/config.d.ts.map +1 -1
  27. package/lib/types/image-input-variants.d.ts +153 -0
  28. package/lib/types/image-input-variants.d.ts.map +1 -0
  29. package/lib/types/index.d.ts.map +1 -1
  30. package/lib/types/paste-images.d.ts +35 -0
  31. package/lib/types/paste-images.d.ts.map +1 -1
  32. package/lib/types/runtime.d.ts +5 -2
  33. package/lib/types/runtime.d.ts.map +1 -1
  34. package/lib/types/web-request.d.ts +7 -0
  35. package/lib/types/web-request.d.ts.map +1 -1
  36. package/lib/types/web.d.ts +17 -2
  37. package/lib/types/web.d.ts.map +1 -1
  38. package/lib/web-request.js +11 -2
  39. package/lib/web-request.js.map +1 -1
  40. package/lib/web.js +88 -5
  41. package/lib/web.js.map +1 -1
  42. package/package.json +2 -1
  43. package/src/client/index.tsx +53 -14
  44. package/src/client/paste-images.tsx +331 -15
  45. package/src/config.ts +40 -0
  46. package/src/image-input-variants.ts +688 -0
  47. package/src/index.ts +18 -1
  48. package/src/paste-images.ts +39 -0
  49. package/src/runtime.ts +42 -3
  50. package/src/web-request.ts +12 -2
  51. package/src/web.ts +90 -4
@@ -0,0 +1,688 @@
1
+ /**
2
+ * Image-input variants: sibling model-selector entries for every model the
3
+ * host positively declares text-only. A variant declares image input, so
4
+ * pasted images keep the native attachment flow — composer thumbnail and the
5
+ * durable session image — while the variant's stream rewrites every image
6
+ * block into a Vision Toolkit description before delegating to the original
7
+ * route. The durable log is untouched; only the wire carries text.
8
+ * @module dsh-vision-toolkit/image-input-variants
9
+ */
10
+
11
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
12
+ import { tmpdir } from 'node:os'
13
+ import { join } from 'node:path'
14
+ import type { Context } from '@deepseek-ai/cordis'
15
+ import LlmService, { LlmAdapter, contentHasImage } from '@deepseek-ai/dsh-llm'
16
+ import type {
17
+ ContentBlock,
18
+ GenerateOptions,
19
+ ImageBlock,
20
+ LlmModelInfo,
21
+ LlmProviderInfo,
22
+ LlmResolvedModelInfo,
23
+ Message,
24
+ StreamChunk,
25
+ } from '@deepseek-ai/dsh-llm'
26
+ // Type-only imports activate the host service declarations on Context.
27
+ import type {} from '@deepseek-ai/dsh-session'
28
+ import type {} from '@deepseek-ai/dsh-attachment'
29
+ import type { ResolvedVisionToolkitConfig } from './config.ts'
30
+ import type { PasteSelectionQuery, PasteVerdict } from './paste-images.ts'
31
+ import type { VisionToolkitRuntime } from './runtime.ts'
32
+
33
+ /** Provider-id prefix for the variant routes this plugin registers. */
34
+ export const VARIANT_PROVIDER_PREFIX = 'vision-toolkit-'
35
+
36
+ /** Display suffix shared by variant provider names and variant model names. */
37
+ export const VARIANT_SUFFIX = ' (Vision Toolkit)'
38
+
39
+ /** Promise-cache bound for image descriptions, so a long-lived Web profile cannot hoard evidence text. */
40
+ const EVIDENCE_CACHE_LIMIT = 64
41
+
42
+ /**
43
+ * Media types the Vision Toolkit glance pipeline accepts, by declared media
44
+ * type. Narrower than the paste-to-workspace route (which stores any image
45
+ * type): a paste of an unsupported type on a variant session degrades loudly
46
+ * on the wire instead of being described.
47
+ */
48
+ const MEDIA_EXTENSIONS: Readonly<Record<string, string>> = {
49
+ 'image/png': '.png',
50
+ 'image/jpeg': '.jpg',
51
+ 'image/webp': '.webp',
52
+ 'image/gif': '.gif',
53
+ }
54
+
55
+ /** Model-facing prefix on converted image blocks. */
56
+ const DESCRIBED_PREFIX = '[Image described by the Vision Toolkit]\n'
57
+
58
+ /** Model-facing prefix on degraded conversions; the model must never guess at image content. */
59
+ const DEGRADED_PREFIX = '[The Vision Toolkit could not describe this image: '
60
+
61
+ /** The variant provider route minted for one upstream route. */
62
+ export function variantProviderId(upstream: string): string {
63
+ return `${VARIANT_PROVIDER_PREFIX}${upstream}`
64
+ }
65
+
66
+ /**
67
+ * Whether one model earns an image-input variant: the host must positively
68
+ * declare it text-only. A model with unknown modalities is left alone — its
69
+ * native channel is the safe default, and the variant would degrade it.
70
+ * @param info - model metadata from the host catalog.
71
+ * @returns true when the model is confirmed text-only.
72
+ */
73
+ export function shouldWrapModel(info: Pick<LlmModelInfo, 'inputModalities'>): boolean {
74
+ return Array.isArray(info.inputModalities) && !info.inputModalities.includes('image')
75
+ }
76
+
77
+ /** Whether a content block list carries an image at any depth (tool-result nesting included). */
78
+ export { contentHasImage } from '@deepseek-ai/dsh-llm'
79
+
80
+ function messageOf(error: unknown): string {
81
+ return error instanceof Error ? error.message : String(error)
82
+ }
83
+
84
+ /** Bounded promise cache for one attachment's description; failed reads are not retained. */
85
+ export class EvidenceCache {
86
+ private readonly entries = new Map<string, Promise<ContentBlock>>()
87
+
88
+ constructor(private readonly limit: number) {}
89
+
90
+ /**
91
+ * Read one key's entry or compute it. Concurrent readers join the in-flight
92
+ * computation; a settled failure is evicted so a fixed configuration gets a
93
+ * fresh chance.
94
+ * @param key - the attachment identity (content-addressed).
95
+ * @param load - computes the description; must resolve `{ ok, block }` and never reject.
96
+ * @returns the cached or computed block.
97
+ */
98
+ read(key: string, load: () => Promise<{ ok: boolean; block: ContentBlock }>): Promise<ContentBlock> {
99
+ const existing = this.entries.get(key)
100
+ if (existing !== undefined) {
101
+ // Refresh recency: Map iteration order is insertion order.
102
+ this.entries.delete(key)
103
+ this.entries.set(key, existing)
104
+ return existing
105
+ }
106
+ const pending = load().then(
107
+ (result) => {
108
+ // Only evict our own entry: this promise may have been LRU-evicted and
109
+ // the key re-populated by a newer read meanwhile.
110
+ if (!result.ok && this.entries.get(key) === pending) {
111
+ this.entries.delete(key)
112
+ }
113
+ return result.block
114
+ },
115
+ (error: unknown) => {
116
+ if (this.entries.get(key) === pending) {
117
+ this.entries.delete(key)
118
+ }
119
+ throw error
120
+ },
121
+ )
122
+ this.entries.set(key, pending)
123
+ while (this.entries.size > this.limit) {
124
+ const oldest = this.entries.keys().next().value
125
+ if (oldest === undefined) break
126
+ this.entries.delete(oldest)
127
+ }
128
+ return pending
129
+ }
130
+
131
+ /** Drop every cached description (runtime reconfiguration invalidates provider-specific reads). */
132
+ clear(): void {
133
+ this.entries.clear()
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Wait on a shared promise without inheriting its lifetime: the caller's
139
+ * abort rejects this wait immediately, while the underlying read keeps
140
+ * running and lands in the cache for the retry.
141
+ * @param promise - the shared computation.
142
+ * @param signal - the caller's cancellation, or undefined to wait unconditionally.
143
+ * @returns the computed value, unless the caller aborted first.
144
+ */
145
+ export function abortableWait<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
146
+ if (signal === undefined) return promise
147
+ return new Promise((resolve, reject) => {
148
+ if (signal.aborted) {
149
+ reject(signal.reason ?? new Error('aborted'))
150
+ return
151
+ }
152
+ const onAbort = (): void => { reject(signal.reason ?? new Error('aborted')) }
153
+ signal.addEventListener('abort', onAbort, { once: true })
154
+ promise.then(
155
+ (value) => {
156
+ signal.removeEventListener('abort', onAbort)
157
+ resolve(value)
158
+ },
159
+ (error: unknown) => {
160
+ signal.removeEventListener('abort', onAbort)
161
+ reject(error)
162
+ },
163
+ )
164
+ })
165
+ }
166
+
167
+ async function convertBlocks(
168
+ blocks: readonly ContentBlock[],
169
+ convert: (block: ImageBlock) => Promise<ContentBlock>,
170
+ ): Promise<ContentBlock[]> {
171
+ const out: ContentBlock[] = []
172
+ for (const block of blocks) {
173
+ if (block.type === 'image') {
174
+ out.push(await convert(block))
175
+ } else if (block.type === 'tool-result' && contentHasImage(block.content)) {
176
+ out.push({ ...block, content: await convertBlocks(block.content, convert) })
177
+ } else {
178
+ out.push(block)
179
+ }
180
+ }
181
+ return out
182
+ }
183
+
184
+ /**
185
+ * Read one image block into a Vision Toolkit description text block. Never
186
+ * throws: failures degrade to an explanatory block with `ok: false`, so the
187
+ * caller can decide what a failure means (the cache refuses to memoize it).
188
+ * @param ctx - plugin context; reads the optional `attachments` service.
189
+ * @param runtime - the currently serving Vision Toolkit runtime, if ready.
190
+ * @param block - the image block to describe.
191
+ * @returns the outcome and its model-facing replacement block.
192
+ */
193
+ async function readImageBlock(
194
+ ctx: Context,
195
+ runtime: () => VisionToolkitRuntime | undefined,
196
+ block: ImageBlock,
197
+ ): Promise<{ ok: boolean; block: ContentBlock }> {
198
+ const attachments = ctx.get('attachments')
199
+ const current = runtime()
200
+ if (attachments === undefined || current === undefined) {
201
+ return { ok: false, block: { type: 'text', text: `${DEGRADED_PREFIX}the Vision Toolkit runtime is not ready.]` } }
202
+ }
203
+ const extension = MEDIA_EXTENSIONS[block.attachment.mediaType]
204
+ if (extension === undefined) {
205
+ return { ok: false, block: { type: 'text', text: `${DEGRADED_PREFIX}unsupported image media type ${block.attachment.mediaType}.]` } }
206
+ }
207
+ let directory: string | undefined
208
+ try {
209
+ // The glance pipeline validates paths against the workspace it is given,
210
+ // so the temp copy lives in a dedicated directory passed as that workspace.
211
+ const stored = await attachments.readImage(block.attachment)
212
+ directory = await mkdtemp(join(tmpdir(), 'dsh-vision-toolkit-'))
213
+ const file = join(directory, `image${extension}`)
214
+ await writeFile(file, Buffer.from(stored.data), { mode: 0o600 })
215
+ // A fresh signal on purpose: the cached run must not die with its first
216
+ // caller (their abort used to cancel every concurrent joiner); the runtime
217
+ // deadline still bounds it.
218
+ const result = await current.glance(
219
+ { images: [file] },
220
+ { signal: new AbortController().signal, workspace: directory },
221
+ )
222
+ const answer = result.answer.trim()
223
+ if (answer.length === 0) throw new Error('the Vision Toolkit returned an empty description')
224
+ return { ok: true, block: { type: 'text', text: `${DESCRIBED_PREFIX}${answer}` } }
225
+ } catch (error) {
226
+ return {
227
+ ok: false,
228
+ block: { type: 'text', text: `${DEGRADED_PREFIX}${messageOf(error).slice(0, 300)}.]` },
229
+ }
230
+ } finally {
231
+ if (directory !== undefined) {
232
+ await rm(directory, { recursive: true, force: true }).catch(() => {})
233
+ }
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Rewrite image blocks in one message list into description text blocks.
239
+ * The original messages are returned untouched when nothing carries an image;
240
+ * converted messages are new objects, so the durable request stays immutable.
241
+ * @param ctx - plugin context for the attachments service.
242
+ * @param runtime - the currently serving runtime (lazily read per conversion).
243
+ * @param cache - shared per-adapter description cache.
244
+ * @param messages - the assembled request messages.
245
+ * @param signal - the caller's cancellation for this conversion pass.
246
+ * @returns the rewritten message list.
247
+ */
248
+ export async function convertImagesToEvidence(
249
+ ctx: Context,
250
+ runtime: () => VisionToolkitRuntime | undefined,
251
+ cache: EvidenceCache,
252
+ messages: readonly Message[],
253
+ signal?: AbortSignal,
254
+ ): Promise<Message[]> {
255
+ const out: Message[] = []
256
+ for (const message of messages) {
257
+ if (!contentHasImage(message.content)) {
258
+ out.push(message)
259
+ continue
260
+ }
261
+ const content = await convertBlocks(message.content, (block) =>
262
+ abortableWait(cache.read(String(block.attachment.attachmentId), () =>
263
+ readImageBlock(ctx, runtime, block)), signal))
264
+ out.push({ ...message, content })
265
+ }
266
+ return out
267
+ }
268
+
269
+ /**
270
+ * The adapter behind one variant route: model metadata declares image input,
271
+ * and every stream rewrites image blocks before delegating to the upstream
272
+ * route through the host service (so the upstream route's own middleware,
273
+ * retry policy, and replay handling still apply).
274
+ */
275
+ export class ImageInputVariantAdapter extends LlmAdapter {
276
+ private lastRuntime: VisionToolkitRuntime | undefined
277
+
278
+ constructor(
279
+ private readonly ctx: Context,
280
+ private readonly llm: LlmService,
281
+ private readonly upstream: string,
282
+ private readonly upstreamName: string,
283
+ private readonly runtime: () => VisionToolkitRuntime | undefined,
284
+ private readonly cache: EvidenceCache,
285
+ ) {
286
+ super()
287
+ }
288
+
289
+ override providerInfo(provider: string): LlmProviderInfo {
290
+ return { id: provider, name: `${this.upstreamName}${VARIANT_SUFFIX}` }
291
+ }
292
+
293
+ override async listModels(provider: string): Promise<readonly LlmModelInfo[]> {
294
+ const models = await this.llm.listModels(this.upstream)
295
+ return models.filter(shouldWrapModel).map((model) => ({
296
+ provider,
297
+ id: model.id,
298
+ name: `${model.name}${VARIANT_SUFFIX}`,
299
+ inputModalities: ['text', 'image'],
300
+ ...(model.description === undefined ? {} : { description: model.description }),
301
+ }))
302
+ }
303
+
304
+ override async resolveModel(
305
+ provider: string,
306
+ model: string,
307
+ signal?: AbortSignal,
308
+ ): Promise<LlmResolvedModelInfo> {
309
+ const info = await this.llm.resolveModelInfo(this.upstream, model, signal)
310
+ if (!shouldWrapModel(info)) {
311
+ throw new Error(`model "${model}" is not a text-only model and needs no image-input variant`)
312
+ }
313
+ return {
314
+ provider,
315
+ id: model,
316
+ name: `${info.name}${VARIANT_SUFFIX}`,
317
+ inputModalities: ['text', 'image'],
318
+ ...(info.description === undefined ? {} : { description: info.description }),
319
+ // Capability and call-default metadata rides through unchanged: the
320
+ // variant is a wire-only facade, so context capacity, output caps, and
321
+ // reasoning efforts must behave exactly like the upstream route.
322
+ ...(info.context === undefined ? {} : { context: info.context }),
323
+ ...(info.defaultMaxTokens === undefined ? {} : { defaultMaxTokens: info.defaultMaxTokens }),
324
+ ...(info.reasoning === undefined ? {} : { reasoning: info.reasoning }),
325
+ }
326
+ }
327
+
328
+ override async *stream(options: GenerateOptions): AsyncGenerator<StreamChunk> {
329
+ // A reconfigured runtime is a NEW instance; descriptions read through the
330
+ // previous provider must not be replayed for the new one.
331
+ const current = this.runtime()
332
+ if (current !== this.lastRuntime) {
333
+ this.cache.clear()
334
+ this.lastRuntime = current
335
+ }
336
+ const messages = await convertImagesToEvidence(
337
+ this.ctx,
338
+ this.runtime,
339
+ this.cache,
340
+ options.messages,
341
+ options.signal,
342
+ )
343
+ // Delegate through the host service under the upstream route: the variant
344
+ // is a wire-only facade, and the upstream route owns retry and replay.
345
+ yield* this.llm.stream({ ...options, provider: this.upstream, messages })
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Whether the plugin should take a paste over for one live Session: true only
351
+ * when the current model is positively declared text-only. The model-selector
352
+ * label is the authoritative source when supplied — the Session's persisted
353
+ * route header only updates on a request, so a model switch would otherwise be
354
+ * invisible until the next turn — with a fallback to that header. Unknown
355
+ * routes answer false: the native attachment flow is the safe default, and a
356
+ * text-only model merely keeps its ordinary image-admission error.
357
+ * @param ctx - plugin context with `sessions` and `llm`.
358
+ * @param sessionId - the live Session id the paste belongs to.
359
+ * @param modelLabel - the model-selector label the client currently shows, if any.
360
+ * @returns true when pastes should become workspace paths instead of attachments.
361
+ */
362
+ export async function sessionPasteTakeover(
363
+ ctx: Context,
364
+ sessionId: string,
365
+ modelLabel?: string,
366
+ ): Promise<boolean> {
367
+ if (modelLabel !== undefined && modelLabel.trim() !== '') {
368
+ const byLabel = await labelTakeoverVerdict(ctx, modelLabel)
369
+ if (byLabel !== undefined) return byLabel
370
+ }
371
+ return sessionHeaderTakeover(ctx, sessionId)
372
+ }
373
+
374
+ /**
375
+ * Resolve the takeover verdict from the Session's last requested route header.
376
+ * @param ctx - plugin context with `sessions` and `llm`.
377
+ * @param sessionId - the live Session id.
378
+ * @returns true when the persisted route is positively text-only.
379
+ */
380
+ export async function sessionHeaderTakeover(ctx: Context, sessionId: string): Promise<boolean> {
381
+ const session = ctx.sessions.get(sessionId as never)
382
+ if (session === undefined) return false
383
+ const routed = session.requestHeader()?.config
384
+ if (routed === undefined) return false
385
+ const llm = ctx.get('llm')
386
+ if (llm === undefined) return false
387
+ let info: LlmResolvedModelInfo
388
+ try {
389
+ info = await llm.resolveModelInfo(routed.provider, routed.model)
390
+ } catch {
391
+ return false
392
+ }
393
+ return shouldWrapModel(info)
394
+ }
395
+
396
+ /**
397
+ * Resolve the takeover verdict from a model-selector label alone. Every model
398
+ * whose name or id appears in the label votes: any image-capable (or unknown-
399
+ * capability) match vetoes the takeover, and at least one positively text-only
400
+ * match confirms it. A route whose catalog cannot be read also vetoes — the
401
+ * unreadable route is exactly where an image-capable twin could hide, so a
402
+ * label match on a half-read catalog must not confirm a takeover. The label
403
+ * carries no provider id, so no picking is attempted: the answer is decisive
404
+ * only when the whole catalog was walkable and every match agrees.
405
+ * @param ctx - plugin context with the `llm` service.
406
+ * @param label - the selector label the browser shows.
407
+ * @returns true (take over), false (native), or undefined when nothing matched.
408
+ */
409
+ export async function labelTakeoverVerdict(ctx: Context, label: string): Promise<boolean | undefined> {
410
+ const llm = ctx.get('llm')
411
+ if (llm === undefined) return undefined
412
+ const lowered = label.toLowerCase()
413
+ let matchedTextOnly = false
414
+ for (const provider of llm.listProviders()) {
415
+ let models: readonly LlmModelInfo[]
416
+ try {
417
+ models = await llm.listModels(provider.id)
418
+ } catch (error) {
419
+ // An unreadable route cannot vote — and it is exactly where an
420
+ // image-capable twin could hide (a variant route probes its upstream).
421
+ // A label match on a half-read catalog must not confirm a takeover, so
422
+ // the verdict is vetoed for THIS label; the session header fallback is
423
+ // deliberately not used here because it may still describe a previous
424
+ // model after a switch. Loud, so a broken provider is diagnosable.
425
+ ctx.logger.warn(
426
+ 'dsh-vision-toolkit: paste verdict could not read route "%s"; native paste wins for this label. %s',
427
+ provider.id,
428
+ messageOf(error).slice(0, 300),
429
+ )
430
+ return false
431
+ }
432
+ for (const model of models) {
433
+ for (const candidate of [model.name, model.id]) {
434
+ if (typeof candidate !== 'string' || candidate.length === 0) continue
435
+ if (!lowered.includes(candidate.toLowerCase())) continue
436
+ if (!shouldWrapModel(model)) {
437
+ // An image-capable or unconfirmed model in the label keeps its
438
+ // native paste; the variant routes declare image input and are
439
+ // covered by this veto.
440
+ return false
441
+ }
442
+ // Positive confirmation has a floor: one- and two-character names
443
+ // match label prose far too easily to identify the selected model.
444
+ if (candidate.length >= 3) matchedTextOnly = true
445
+ }
446
+ }
447
+ }
448
+ return matchedTextOnly ? true : undefined
449
+ }
450
+
451
+ /** Label-verdict cache bound, so a long-lived Web profile cannot hoard catalog walks. */
452
+ const LABEL_VERDICT_TTL_MS = 15_000
453
+ const LABEL_VERDICT_CAP = 32
454
+
455
+ /**
456
+ * Resolve the paste verdict for one exact model route. Image-capable (or
457
+ * unresolvable) routes keep the native flow; a text-only route whose
458
+ * image-input variant is registered gets an auto-switch instruction; a
459
+ * text-only route without a usable variant falls back to the path takeover.
460
+ * @param ctx - plugin context with the `llm` service.
461
+ * @param getConfig - resolves the current plugin configuration.
462
+ * @param selection - the exact provider/model the browser currently selects.
463
+ * @returns the verdict for that route.
464
+ */
465
+ async function routePasteVerdict(
466
+ ctx: Context,
467
+ getConfig: () => ResolvedVisionToolkitConfig,
468
+ selection: PasteSelectionQuery,
469
+ ): Promise<PasteVerdict> {
470
+ const llm = ctx.get('llm')
471
+ if (llm === undefined) return { takeOver: false }
472
+ let info: LlmResolvedModelInfo
473
+ try {
474
+ info = await llm.resolveModelInfo(selection.provider, selection.model)
475
+ } catch {
476
+ // An unresolvable route keeps the native flow; the host's own admission
477
+ // error is the honest answer for a model that cannot take images.
478
+ return { takeOver: false }
479
+ }
480
+ if (!shouldWrapModel(info)) return { takeOver: false }
481
+ const variants = getConfig().imageInputVariants
482
+ // Auto-switch is an opt-in refinement of the takeover: off means the
483
+ // text-only route keeps its ordinary path takeover.
484
+ if (!variants.enabled || !variants.autoSwitch) return { takeOver: true }
485
+ const variantProvider = variantProviderId(selection.provider)
486
+ try {
487
+ if (!llm.listProviders().some(provider => provider.id === variantProvider)) return { takeOver: true }
488
+ const models = await llm.listModels(variantProvider)
489
+ const twin = models.find(model => model.id === selection.model)
490
+ // The variant only wraps text-only models, so membership confirms both
491
+ // the route and the wrap; anything else keeps the path takeover.
492
+ if (twin === undefined) return { takeOver: true }
493
+ return {
494
+ takeOver: false,
495
+ autoSwitch: {
496
+ provider: variantProvider,
497
+ model: selection.model,
498
+ label: twin.name,
499
+ ...(selection.reasoningEffort === undefined ? {} : { reasoningEffort: selection.reasoningEffort }),
500
+ },
501
+ }
502
+ } catch {
503
+ return { takeOver: true }
504
+ }
505
+ }
506
+
507
+ /**
508
+ * Paste-policy resolver with a short cache. The exact route is the live fact
509
+ * (the browser re-reads it per paste), and the host catalog only changes on
510
+ * topology events, so a brief cache is safe; every `llm/adapters-updated`
511
+ * notification empties it — including the sweep that registers a variant
512
+ * after the first sweep, so a stale "no variant" verdict cannot outlive the
513
+ * route it described.
514
+ * @param ctx - plugin context with the `llm` service.
515
+ * @param getConfig - resolves the current plugin configuration per verdict.
516
+ * @returns the cached verdict resolver for the Web paste-policy route.
517
+ */
518
+ export function createPasteTakeoverResolver(
519
+ ctx: Context,
520
+ getConfig: () => ResolvedVisionToolkitConfig,
521
+ ): (sessionId: string, selection?: PasteSelectionQuery, modelLabel?: string) => Promise<PasteVerdict> {
522
+ const routes = new Map<string, { verdict: PasteVerdict; at: number }>()
523
+ const labels = new Map<string, { takeOver: boolean | undefined; at: number }>()
524
+ const trim = (map: Map<string, unknown>): void => {
525
+ while (map.size > LABEL_VERDICT_CAP) {
526
+ const oldest = map.keys().next().value
527
+ if (oldest === undefined) break
528
+ map.delete(oldest)
529
+ }
530
+ }
531
+ if (typeof ctx.on === 'function') {
532
+ ctx.on('llm/adapters-updated', () => {
533
+ routes.clear()
534
+ labels.clear()
535
+ })
536
+ }
537
+ return async (sessionId, selection, modelLabel) => {
538
+ if (selection !== undefined && selection.provider.trim() !== '' && selection.model.trim() !== '') {
539
+ const key = `route:${selection.provider}|${selection.model}`
540
+ const cached = routes.get(key)
541
+ if (cached !== undefined && Date.now() - cached.at <= LABEL_VERDICT_TTL_MS) return cached.verdict
542
+ const verdict = await routePasteVerdict(ctx, getConfig, selection)
543
+ routes.set(key, { verdict, at: Date.now() })
544
+ trim(routes)
545
+ return verdict
546
+ }
547
+ if (modelLabel !== undefined && modelLabel.trim() !== '') {
548
+ const key = `label:${modelLabel}`
549
+ const cached = labels.get(key)
550
+ if (cached !== undefined && Date.now() - cached.at <= LABEL_VERDICT_TTL_MS) {
551
+ return { takeOver: cached.takeOver ?? (await sessionHeaderTakeover(ctx, sessionId)) }
552
+ }
553
+ const verdict = await labelTakeoverVerdict(ctx, modelLabel)
554
+ // A decisive answer AND a miss are both cached: a label that matches
555
+ // nothing would otherwise pay a full catalog walk on every paste.
556
+ labels.set(key, { takeOver: verdict, at: Date.now() })
557
+ trim(labels)
558
+ return { takeOver: verdict ?? (await sessionHeaderTakeover(ctx, sessionId)) }
559
+ }
560
+ return { takeOver: await sessionHeaderTakeover(ctx, sessionId) }
561
+ }
562
+ }
563
+
564
+ /**
565
+ * Register and maintain one variant route per eligible upstream route. Routes
566
+ * that later vanish are released; routes that gain eligible models later are
567
+ * picked up by the next sweep (host topology notifications included).
568
+ * @param ctx - plugin context with the `llm` service.
569
+ * @param getConfig - resolves the current plugin configuration per sweep.
570
+ * @param getRuntime - the currently serving Vision Toolkit runtime, if ready.
571
+ * @returns the disposer and a manual re-sweep trigger (settings changes).
572
+ */
573
+ export function installImageInputVariants(
574
+ ctx: Context,
575
+ getConfig: () => ResolvedVisionToolkitConfig,
576
+ getRuntime: () => VisionToolkitRuntime | undefined,
577
+ ): { dispose: () => void; reconcile: () => void } {
578
+ const registrations = new Map<string, () => void>()
579
+ let disposed = false
580
+ // Serialize sweeps: a registration itself announces llm/adapters-updated,
581
+ // and two interleaved sweeps must never probe the same route concurrently.
582
+ let sweeping: Promise<void> = Promise.resolve()
583
+ // Coalesce bursts: a sweep triggered while one is pending is one extra pass,
584
+ // not one per notification (a single registration emits a notification).
585
+ let sweepQueued = false
586
+
587
+ const releaseAll = (): void => {
588
+ for (const dispose of [...registrations.values()]) dispose()
589
+ registrations.clear()
590
+ }
591
+
592
+ const sweep = (): void => {
593
+ if (sweepQueued) return
594
+ sweepQueued = true
595
+ queueMicrotask(() => {
596
+ sweepQueued = false
597
+ sweeping = sweeping.then(sweepOnce, sweepOnce)
598
+ })
599
+ }
600
+
601
+ const sweepOnce = async (): Promise<void> => {
602
+ if (disposed) return
603
+ try {
604
+ const variants = getConfig().imageInputVariants
605
+ if (!variants.enabled) {
606
+ releaseAll()
607
+ return
608
+ }
609
+ const llm = ctx.get('llm')
610
+ if (llm === undefined) return
611
+ const restrict = new Set(variants.providers)
612
+ let providers: LlmProviderInfo[]
613
+ try {
614
+ providers = llm.listProviders()
615
+ } catch {
616
+ return
617
+ }
618
+ for (const provider of providers) {
619
+ const upstream = provider.id
620
+ if (restrict.size > 0 && !restrict.has(upstream)) continue
621
+ // Our own variant routes declare image input and are never wrapped;
622
+ // probing them would just re-probe their upstream route.
623
+ if (upstream.startsWith(VARIANT_PROVIDER_PREFIX)) continue
624
+ let models: readonly LlmModelInfo[]
625
+ try {
626
+ models = await llm.listModels(upstream)
627
+ } catch {
628
+ continue
629
+ }
630
+ const eligible = models.some(shouldWrapModel)
631
+ const registered = registrations.has(upstream)
632
+ if (!eligible && registered) {
633
+ // The route lost its eligible models: release the stale variant.
634
+ const dispose = registrations.get(upstream)
635
+ dispose?.()
636
+ registrations.delete(upstream)
637
+ continue
638
+ }
639
+ if (!eligible || registered) continue
640
+ if (disposed) return
641
+ try {
642
+ const dispose = llm.registerAdapter(
643
+ [variantProviderId(upstream)],
644
+ new ImageInputVariantAdapter(
645
+ ctx,
646
+ llm,
647
+ upstream,
648
+ provider.name,
649
+ getRuntime,
650
+ new EvidenceCache(EVIDENCE_CACHE_LIMIT),
651
+ ),
652
+ )
653
+ registrations.set(upstream, dispose)
654
+ } catch (error) {
655
+ ctx.logger.warn(
656
+ 'dsh-vision-toolkit: image-input variant registration skipped for "%s": %s',
657
+ upstream,
658
+ messageOf(error),
659
+ )
660
+ }
661
+ }
662
+ const live = new Set(providers.map(provider => provider.id))
663
+ for (const [upstream, dispose] of [...registrations]) {
664
+ // A wrapper is released when its upstream route vanished OR the
665
+ // current configuration no longer allows that route (restrict
666
+ // narrowing must not leave stale variants behind).
667
+ if (!live.has(upstream) || (restrict.size > 0 && !restrict.has(upstream))) {
668
+ dispose()
669
+ registrations.delete(upstream)
670
+ }
671
+ }
672
+ } catch (error) {
673
+ ctx.logger.warn('dsh-vision-toolkit: image-input variant sweep failed: %s', messageOf(error))
674
+ }
675
+ }
676
+
677
+ if (typeof ctx.on === 'function') {
678
+ ctx.on('llm/adapters-updated', () => { sweep() })
679
+ }
680
+ sweep()
681
+ return {
682
+ dispose: () => {
683
+ disposed = true
684
+ releaseAll()
685
+ },
686
+ reconcile: () => { sweep() },
687
+ }
688
+ }