@mengruo/dsh-vision-toolkit 0.0.1 → 0.1.1

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 +17 -42
  3. package/README.zh.md +16 -41
  4. package/assets/logo_eapi_dark.png +0 -0
  5. package/docs/aihubmix-gemini-vision.i18n.yaml +2 -2
  6. package/docs/aihubmix-gemini-vision.md +2 -2
  7. package/docs/aihubmix-gemini-vision.zh.md +2 -2
  8. package/lib/client.js +246 -68
  9. package/lib/client.js.map +1 -1
  10. package/lib/config.js +143 -24
  11. package/lib/config.js.map +1 -1
  12. package/lib/evidence-cache.js +14 -0
  13. package/lib/evidence-cache.js.map +1 -1
  14. package/lib/image-input-variants.js +48 -24
  15. package/lib/image-input-variants.js.map +1 -1
  16. package/lib/runtime-install.js +204 -19
  17. package/lib/runtime-install.js.map +1 -1
  18. package/lib/runtime.js +310 -154
  19. package/lib/runtime.js.map +1 -1
  20. package/lib/tools.js +1 -0
  21. package/lib/tools.js.map +1 -1
  22. package/lib/types/client/index.d.ts +45 -3
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/config.d.ts +53 -0
  25. package/lib/types/config.d.ts.map +1 -1
  26. package/lib/types/evidence-cache.d.ts.map +1 -1
  27. package/lib/types/image-input-variants.d.ts +9 -4
  28. package/lib/types/image-input-variants.d.ts.map +1 -1
  29. package/lib/types/runtime-install.d.ts +21 -0
  30. package/lib/types/runtime-install.d.ts.map +1 -1
  31. package/lib/types/runtime.d.ts +45 -10
  32. package/lib/types/runtime.d.ts.map +1 -1
  33. package/lib/types/tools.d.ts.map +1 -1
  34. package/lib/types/upstream.d.ts +3 -0
  35. package/lib/types/upstream.d.ts.map +1 -1
  36. package/lib/types/web.d.ts +8 -0
  37. package/lib/types/web.d.ts.map +1 -1
  38. package/lib/upstream.js +11 -6
  39. package/lib/upstream.js.map +1 -1
  40. package/lib/web.js +50 -7
  41. package/lib/web.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/client/index.tsx +331 -88
  44. package/src/config.ts +210 -28
  45. package/src/evidence-cache.ts +14 -0
  46. package/src/image-input-variants.ts +54 -22
  47. package/src/runtime-install.ts +231 -20
  48. package/src/runtime.ts +333 -180
  49. package/src/tools.ts +1 -0
  50. package/src/upstream.ts +15 -8
  51. package/src/web.ts +67 -8
package/src/config.ts CHANGED
@@ -44,6 +44,38 @@ const BUILT_IN_FREE_VISION_MODEL_ALIASES = new Set([
44
44
  'moondream3.1-9B-A2B',
45
45
  ])
46
46
 
47
+ /** One online vision provider in the failover pool. */
48
+ export interface VisionProviderConfig {
49
+ /** Stable unique id used to derive the auto-generated credential name. */
50
+ id?: string
51
+ /** Display label shown in Settings; defaults to the model name. */
52
+ name?: string
53
+ /** Whether this provider participates in the failover pool (default true). */
54
+ enabled?: boolean
55
+ /** Provider API base URL. */
56
+ baseUrl?: string
57
+ /** DSH Credential reference holding the API key (an environment-style name). */
58
+ credential?: string
59
+ /** Multimodal model name. */
60
+ model?: string
61
+ /** Vision request protocol: OpenAI Chat Completions or Anthropic Messages. */
62
+ protocol?: 'openai' | 'anthropic'
63
+ /** Anthropic thinking field behavior; `omit` leaves model defaults untouched. */
64
+ anthropicThinking?: 'omit' | 'disabled' | 'adaptive'
65
+ /** Outbound User-Agent for provider requests and connection tests. */
66
+ userAgent?: string
67
+ /** Per-provider single request timeout in milliseconds. */
68
+ timeoutMs?: number
69
+ /** Per-provider maximum input image bytes; larger images skip to a later provider or are compressed. */
70
+ maxImageBytes?: number
71
+ /** Per-provider maximum decoded pixel count per input image. */
72
+ maxImagePixels?: number
73
+ /** Per-provider in-flight request cap; an exhausted provider is skipped in favor of the next one. */
74
+ concurrency?: number
75
+ /** Total attempts against this provider before failing over to the next one (default 3). */
76
+ attempts?: number
77
+ }
78
+
47
79
  /** Full user-facing configuration; every field defaults at the schema boundary. */
48
80
  export interface VisionToolkitConfig {
49
81
  provider?: {
@@ -60,6 +92,8 @@ export interface VisionToolkitConfig {
60
92
  /** Outbound User-Agent for provider requests and connection tests. */
61
93
  userAgent?: string
62
94
  }
95
+ /** Ordered online vision providers; array order is the failover priority. */
96
+ providers?: VisionProviderConfig[]
63
97
  /** Vision output language (`zh` or `en`). */
64
98
  language?: 'zh' | 'en'
65
99
  /** Single remote/upstream call budget in milliseconds. */
@@ -122,6 +156,22 @@ export const Config: Schema<VisionToolkitConfig> = z.object({
122
156
  anthropicThinking: z.union(['omit', 'disabled', 'adaptive'] as const).default('omit'),
123
157
  userAgent: z.string().default(DEFAULT_VISION_USER_AGENT),
124
158
  }),
159
+ providers: z.array(z.object({
160
+ name: z.string(),
161
+ enabled: z.boolean().default(true),
162
+ id: z.string(),
163
+ baseUrl: z.string(),
164
+ credential: z.string(),
165
+ model: z.string(),
166
+ protocol: z.union(['openai', 'anthropic'] as const).default('openai'),
167
+ anthropicThinking: z.union(['omit', 'disabled', 'adaptive'] as const).default('omit'),
168
+ userAgent: z.string(),
169
+ timeoutMs: z.number(),
170
+ maxImageBytes: z.number(),
171
+ maxImagePixels: z.number(),
172
+ concurrency: z.number(),
173
+ attempts: z.number(),
174
+ })).default([]),
125
175
  language: z.union(['zh', 'en'] as const).default('zh'),
126
176
  timeoutMs: z.number().default(30000),
127
177
  maxImageBytes: z.number().default(4194304),
@@ -141,6 +191,25 @@ export const Config: Schema<VisionToolkitConfig> = z.object({
141
191
  }),
142
192
  })
143
193
 
194
+ /** One resolved online vision provider, with every default materialized. */
195
+ export interface ResolvedProvider {
196
+ /** Stable unique id used to derive the auto-generated credential name. */
197
+ id?: string
198
+ name: string
199
+ enabled: boolean
200
+ baseUrl: string
201
+ credential: CredentialRef
202
+ model: string
203
+ protocol: 'openai' | 'anthropic'
204
+ anthropicThinking: 'omit' | 'disabled' | 'adaptive'
205
+ userAgent: string
206
+ timeoutMs: number
207
+ maxImageBytes: number
208
+ maxImagePixels: number
209
+ concurrency: number
210
+ attempts: number
211
+ }
212
+
144
213
  /** Configuration after static validation, with every default materialized. */
145
214
  export interface ResolvedVisionToolkitConfig {
146
215
  provider: {
@@ -151,6 +220,8 @@ export interface ResolvedVisionToolkitConfig {
151
220
  anthropicThinking: 'omit' | 'disabled' | 'adaptive'
152
221
  userAgent: string
153
222
  }
223
+ /** Ordered failover pool; array order is the priority, highest first. */
224
+ providers: ResolvedProvider[]
154
225
  language: 'zh' | 'en'
155
226
  timeoutMs: number
156
227
  maxImageBytes: number
@@ -174,48 +245,141 @@ const MAX_TIMEOUT_MS = 600000
174
245
  const MAX_IMAGE_BYTES = 268435456
175
246
  const MAX_IMAGE_PIXELS = 268435456
176
247
  const MAX_CONCURRENCY = 16
248
+ const MAX_PROVIDERS = 32
249
+ const MAX_PROVIDER_ATTEMPTS = 100
250
+ const DEFAULT_PROVIDER_ATTEMPTS = 3
251
+
252
+ /** Global limits a provider inherits when it does not set its own. */
253
+ interface ProviderDefaults {
254
+ timeoutMs: number
255
+ maxImageBytes: number
256
+ maxImagePixels: number
257
+ concurrency: number
258
+ }
259
+
260
+ /** How strictly one provider's connection fields are validated. */
261
+ type ProviderResolveMode = 'legacy' | 'enabled' | 'disabled'
177
262
 
178
263
  /**
179
- * Validate and normalize a config object (partial inputs receive the same
180
- * defaults the schemastery schema applies). Configuration mistakes fail loud
181
- * at plugin load (the earliest resolvable point); runtime availability is a
182
- * separate, later concern.
183
- * @param config - parsed config with defaults applied.
184
- * @returns the fully defaulted, validated configuration.
264
+ * Resolve and validate one provider (a legacy `provider` entry or an element
265
+ * of `providers`). `enabled` rejects absent or blank connection fields;
266
+ * `legacy` fills absent fields with the built-in free-vision defaults but
267
+ * still rejects explicit blank values; `disabled` is fully lenient.
185
268
  */
186
- export function resolveConfig(config: VisionToolkitConfig = {}): ResolvedVisionToolkitConfig {
187
- const provider = config.provider ?? {}
188
- const runtime = config.runtime ?? {}
189
- const baseUrl = (provider.baseUrl ?? BUILT_IN_FREE_VISION_BASE_URL).trim().replace(/\/+$/, '')
269
+ function resolveProvider(
270
+ input: VisionProviderConfig,
271
+ defaults: ProviderDefaults,
272
+ label: string,
273
+ mode: ProviderResolveMode,
274
+ ): ResolvedProvider {
275
+ const baseUrlInput = input.baseUrl
276
+ const modelInput = input.model
277
+ const credentialInput = input.credential
278
+ const isBlank = (value: string | undefined): boolean => value === undefined || value.trim() === ''
279
+
280
+ let baseUrl: string
281
+ if (mode === 'enabled') {
282
+ if (isBlank(baseUrlInput)) throw new VisionToolkitError('config', `${label}.baseUrl must not be empty`)
283
+ baseUrl = baseUrlInput!.trim().replace(/\/+$/, '')
284
+ } else if (mode === 'legacy') {
285
+ baseUrl = baseUrlInput === undefined ? BUILT_IN_FREE_VISION_BASE_URL : baseUrlInput.trim().replace(/\/+$/, '')
286
+ } else {
287
+ baseUrl = isBlank(baseUrlInput) ? BUILT_IN_FREE_VISION_BASE_URL : baseUrlInput!.trim().replace(/\/+$/, '')
288
+ }
190
289
  if (!/^https?:\/\//i.test(baseUrl) || baseUrl.length <= 'https://'.length) {
191
- throw new VisionToolkitError('config', 'provider.baseUrl must be an http(s) URL')
290
+ throw new VisionToolkitError('config', `${label}.baseUrl must be an http(s) URL`)
291
+ }
292
+
293
+ let model: string
294
+ if (mode === 'enabled') {
295
+ if (isBlank(modelInput)) throw new VisionToolkitError('config', `${label}.model must not be empty`)
296
+ model = modelInput!.trim()
297
+ } else if (mode === 'legacy') {
298
+ model = modelInput === undefined ? BUILT_IN_FREE_VISION_MODEL : modelInput.trim()
299
+ if (model.length === 0) {
300
+ throw new VisionToolkitError('config', `${label}.model must not be empty`)
301
+ }
302
+ } else {
303
+ model = isBlank(modelInput) ? BUILT_IN_FREE_VISION_MODEL : modelInput!.trim()
304
+ }
305
+
306
+ let credentialSource: string
307
+ if (mode === 'enabled') {
308
+ if (isBlank(credentialInput)) throw new VisionToolkitError('config', `${label}.credential must not be empty`)
309
+ credentialSource = credentialInput!.trim()
310
+ } else if (mode === 'legacy') {
311
+ credentialSource = credentialInput === undefined ? BUILT_IN_FREE_VISION_CREDENTIAL : credentialInput.trim()
312
+ } else {
313
+ credentialSource = isBlank(credentialInput) ? BUILT_IN_FREE_VISION_CREDENTIAL : credentialInput!.trim()
192
314
  }
193
315
  let credential: CredentialRef
194
316
  try {
195
- credential = credentialRef((provider.credential ?? BUILT_IN_FREE_VISION_CREDENTIAL).trim())
317
+ credential = credentialRef(credentialSource)
196
318
  } catch (error) {
197
- throw new VisionToolkitError(
198
- 'config',
199
- `provider.credential "${provider.credential ?? BUILT_IN_FREE_VISION_CREDENTIAL}" is not a valid credential reference`,
200
- { cause: error },
201
- )
319
+ throw new VisionToolkitError('config', `${label}.credential "${credentialSource}" is not a valid credential reference`, { cause: error })
202
320
  }
203
- const model = (provider.model ?? BUILT_IN_FREE_VISION_MODEL).trim()
204
- if (model.length === 0) {
205
- throw new VisionToolkitError('config', 'provider.model must not be empty')
206
- }
207
- const protocol = provider.protocol ?? 'openai'
321
+ const protocol = input.protocol ?? 'openai'
208
322
  if (protocol !== 'openai' && protocol !== 'anthropic') {
209
- throw new VisionToolkitError('config', 'provider.protocol must be "openai" or "anthropic"')
323
+ throw new VisionToolkitError('config', `${label}.protocol must be "openai" or "anthropic"`)
210
324
  }
211
- const anthropicThinking = provider.anthropicThinking ?? 'omit'
325
+ const anthropicThinking = input.anthropicThinking ?? 'omit'
212
326
  if (anthropicThinking !== 'omit' && anthropicThinking !== 'disabled' && anthropicThinking !== 'adaptive') {
213
- throw new VisionToolkitError('config', 'provider.anthropicThinking must be "omit", "disabled", or "adaptive"')
327
+ throw new VisionToolkitError('config', `${label}.anthropicThinking must be "omit", "disabled", or "adaptive"`)
214
328
  }
215
- const userAgent = (provider.userAgent ?? DEFAULT_VISION_USER_AGENT).trim()
329
+ const userAgent = (input.userAgent ?? DEFAULT_VISION_USER_AGENT).trim()
216
330
  if (userAgent.length === 0) {
217
- throw new VisionToolkitError('config', 'provider.userAgent must not be empty')
331
+ throw new VisionToolkitError('config', `${label}.userAgent must not be empty`)
218
332
  }
333
+ const timeoutMs = input.timeoutMs ?? defaults.timeoutMs
334
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > MAX_TIMEOUT_MS) {
335
+ throw new VisionToolkitError('config', `${label}.timeoutMs must be an integer between 1000 and ${MAX_TIMEOUT_MS}`)
336
+ }
337
+ const maxImageBytes = input.maxImageBytes ?? defaults.maxImageBytes
338
+ if (!Number.isInteger(maxImageBytes) || maxImageBytes < 1024 || maxImageBytes > MAX_IMAGE_BYTES) {
339
+ throw new VisionToolkitError('config', `${label}.maxImageBytes must be an integer between 1024 and ${MAX_IMAGE_BYTES}`)
340
+ }
341
+ const maxImagePixels = input.maxImagePixels ?? defaults.maxImagePixels
342
+ if (!Number.isInteger(maxImagePixels) || maxImagePixels < 1 || maxImagePixels > MAX_IMAGE_PIXELS) {
343
+ throw new VisionToolkitError('config', `${label}.maxImagePixels must be an integer between 1 and ${MAX_IMAGE_PIXELS}`)
344
+ }
345
+ const concurrency = input.concurrency ?? defaults.concurrency
346
+ if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > MAX_CONCURRENCY) {
347
+ throw new VisionToolkitError('config', `${label}.concurrency must be an integer between 1 and ${MAX_CONCURRENCY}`)
348
+ }
349
+ const attempts = input.attempts ?? DEFAULT_PROVIDER_ATTEMPTS
350
+ if (!Number.isInteger(attempts) || attempts < 1 || attempts > MAX_PROVIDER_ATTEMPTS) {
351
+ throw new VisionToolkitError('config', `${label}.attempts must be an integer between 1 and ${MAX_PROVIDER_ATTEMPTS}`)
352
+ }
353
+ const name = (input.name ?? '').trim()
354
+ const id = (input.id ?? '').trim()
355
+ return {
356
+ ...(id.length === 0 ? {} : { id }),
357
+ name: name.length === 0 ? model : name,
358
+ enabled: input.enabled !== false,
359
+ baseUrl,
360
+ credential,
361
+ model,
362
+ protocol,
363
+ anthropicThinking,
364
+ userAgent,
365
+ timeoutMs,
366
+ maxImageBytes,
367
+ maxImagePixels,
368
+ concurrency,
369
+ attempts,
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Validate and normalize a config object (partial inputs receive the same
375
+ * defaults the schemastery schema applies). Configuration mistakes fail loud
376
+ * at plugin load (the earliest resolvable point); runtime availability is a
377
+ * separate, later concern.
378
+ * @param config - parsed config with defaults applied.
379
+ * @returns the fully defaulted, validated configuration.
380
+ */
381
+ export function resolveConfig(config: VisionToolkitConfig = {}): ResolvedVisionToolkitConfig {
382
+ const runtime = config.runtime ?? {}
219
383
  const language = config.language ?? 'zh'
220
384
  if (language !== 'zh' && language !== 'en') {
221
385
  throw new VisionToolkitError('config', 'language must be "zh" or "en"')
@@ -259,8 +423,26 @@ export function resolveConfig(config: VisionToolkitConfig = {}): ResolvedVisionT
259
423
  const variantProviders = (imageInputVariants.providers ?? [])
260
424
  .map(provider => provider.trim())
261
425
  .filter(provider => provider.length > 0)
426
+ const providerDefaults: ProviderDefaults = { timeoutMs, maxImageBytes, maxImagePixels, concurrency }
427
+ const configuredProviders = config.providers ?? []
428
+ if (configuredProviders.length > MAX_PROVIDERS) {
429
+ throw new VisionToolkitError('config', `providers must have at most ${MAX_PROVIDERS} entries`)
430
+ }
431
+ const providers: ResolvedProvider[] = configuredProviders.length > 0
432
+ ? configuredProviders.map((entry, index) =>
433
+ resolveProvider(entry, providerDefaults, `providers[${index}]`, entry.enabled !== false ? 'enabled' : 'disabled'))
434
+ : [resolveProvider(config.provider ?? {}, providerDefaults, 'provider', 'legacy')]
435
+ const primary = providers.find(entry => entry.enabled) ?? providers[0]!
262
436
  return {
263
- provider: { baseUrl, credential, model, protocol, anthropicThinking, userAgent },
437
+ provider: {
438
+ baseUrl: primary.baseUrl,
439
+ credential: primary.credential,
440
+ model: primary.model,
441
+ protocol: primary.protocol,
442
+ anthropicThinking: primary.anthropicThinking,
443
+ userAgent: primary.userAgent,
444
+ },
445
+ providers,
264
446
  language,
265
447
  timeoutMs,
266
448
  maxImageBytes,
@@ -93,6 +93,20 @@ export function evidenceRuntimeFingerprint(
93
93
  sslVerify: sslVerify ?? null,
94
94
  userAgent: config.provider.userAgent,
95
95
  },
96
+ providers: config.providers.map(provider => ({
97
+ name: provider.name,
98
+ enabled: provider.enabled,
99
+ baseUrl: provider.baseUrl,
100
+ credential: String(provider.credential),
101
+ model: provider.model,
102
+ protocol: provider.protocol,
103
+ anthropicThinking: provider.anthropicThinking,
104
+ userAgent: provider.userAgent,
105
+ maxImageBytes: provider.maxImageBytes,
106
+ maxImagePixels: provider.maxImagePixels,
107
+ concurrency: provider.concurrency,
108
+ attempts: provider.attempts,
109
+ })),
96
110
  language: config.language,
97
111
  timeoutMs: config.timeoutMs,
98
112
  concurrency: config.concurrency,
@@ -95,6 +95,18 @@ export function variantProviderId(upstream: string): string {
95
95
  return `${VARIANT_PROVIDER_PREFIX}${upstream}`
96
96
  }
97
97
 
98
+ /** Stable semantic key for detecting a provider retry-policy replacement. */
99
+ function retryPolicyKey(policy: ResolvedRetryPolicy): string {
100
+ const backoff = [policy.initialDelayMs, policy.maxDelayMs, policy.jitterRatio]
101
+ if (policy.mode === 'always') return JSON.stringify([policy.mode, ...backoff])
102
+ return JSON.stringify([
103
+ policy.mode,
104
+ policy.maxRetries,
105
+ [...policy.retryableCodes].sort(),
106
+ ...backoff,
107
+ ])
108
+ }
109
+
98
110
  /**
99
111
  * Whether one model earns an image-input variant: the host must positively
100
112
  * declare it text-only. A model with unknown modalities is left alone — its
@@ -516,6 +528,10 @@ export class ImageInputVariantAdapter extends LlmAdapter {
516
528
  }
517
529
  }
518
530
 
531
+ override providerRetryPolicy(_provider: string): ResolvedRetryPolicy {
532
+ return this.llm.providerRetryPolicy(this.upstream)
533
+ }
534
+
519
535
  override async listModels(provider: string): Promise<readonly LlmModelInfo[]> {
520
536
  const models = await this.llm.listModels(this.upstream)
521
537
  return models.filter(shouldWrapModel).map((model) => ({
@@ -552,15 +568,22 @@ export class ImageInputVariantAdapter extends LlmAdapter {
552
568
  }
553
569
 
554
570
  /**
555
- * Keep the variant route aligned with the configured upstream route's retry
556
- * policy. This route is a wire-only facade; without this delegation it would
557
- * silently fall back to the host default policy when selected for images.
571
+ * Prepare one model call through this variant generation. Newer host
572
+ * `LlmAdapter` contracts call this before dispatch; mirror the base
573
+ * implementation so the variant route works with any installed dsh-llm
574
+ * version (older bases do not declare the method, hence no `override`).
558
575
  */
559
- override providerRetryPolicy(): ResolvedRetryPolicy | undefined {
560
- try {
561
- return this.llm.providerRetryPolicy(this.upstream)
562
- } catch {
563
- return undefined
576
+ async prepareCall(
577
+ provider: string,
578
+ model: string,
579
+ signal?: AbortSignal,
580
+ ): Promise<{
581
+ readonly model: LlmResolvedModelInfo
582
+ stream(options: GenerateOptions): AsyncIterable<StreamChunk>
583
+ }> {
584
+ return {
585
+ model: await this.resolveModel(provider, model, signal),
586
+ stream: options => this.stream(options),
564
587
  }
565
588
  }
566
589
 
@@ -826,10 +849,9 @@ export function installImageInputVariants(
826
849
  ): { dispose: () => void; reconcile: () => void } {
827
850
  const evidenceStore = new SessionEvidenceStore(ctx)
828
851
  const evidenceCache = new EvidenceCache(EVIDENCE_CACHE_LIMIT, evidenceStore)
829
- const registrations = new Map<string, () => void>()
830
- // The host snapshots adapter provider metadata (including the group display
831
- // name) at registration time, so a transparent-routing toggle must rebuild
832
- // every wrapper for the new names to reach the model selector.
852
+ const registrations = new Map<string, { dispose: () => void; retryPolicyKey: string }>()
853
+ // The host snapshots adapter provider metadata and retry policy at registration
854
+ // time, so changes to either require rebuilding the wrapper route.
833
855
  let lastHidden = false
834
856
  // Upstream ids observed missing and the timestamp of the first missing
835
857
  // observation. A wrapper is only released after its upstream stays absent
@@ -846,12 +868,12 @@ export function installImageInputVariants(
846
868
  let sweepQueued = false
847
869
 
848
870
  const release = (upstream: string): void => {
849
- const dispose = registrations.get(upstream)
850
- if (dispose === undefined) return
871
+ const registration = registrations.get(upstream)
872
+ if (registration === undefined) return
851
873
  registrations.delete(upstream)
852
874
  stale.delete(upstream)
853
875
  try {
854
- dispose()
876
+ registration.dispose()
855
877
  } catch (error) {
856
878
  ctx.logger.warn(
857
879
  'dsh-vision-toolkit: image-input variant release failed for "%s": %s',
@@ -930,18 +952,28 @@ export function installImageInputVariants(
930
952
  continue
931
953
  }
932
954
  const eligible = models.some(shouldWrapModel)
933
- const registered = registrations.has(upstream)
934
- if (registered && live.has(variantId)) {
955
+ let upstreamRetryPolicyKey: string
956
+ try {
957
+ upstreamRetryPolicyKey = retryPolicyKey(llm.providerRetryPolicy(upstream))
958
+ } catch {
959
+ // Preserve an existing wrapper through a transient upstream registry
960
+ // gap; the next topology event or periodic sweep will retry the probe.
961
+ continue
962
+ }
963
+ let registration = registrations.get(upstream)
964
+ if (registration !== undefined && live.has(variantId)) {
935
965
  // Re-read the live registry after the await: a rebuild inside the
936
966
  // probe window can drop our wrapper without touching our map.
937
967
  const liveNow = new Set(llm.listProviders().map(provider => provider.id))
938
968
  if (liveNow.has(variantId)) {
939
- // Healthy handle: only react when the route lost every eligible model.
940
- if (!eligible) release(upstream)
941
- continue
969
+ if (eligible && registration.retryPolicyKey === upstreamRetryPolicyKey) continue
970
+ // Eligibility and retry policy are registration-time facts. Rebuild
971
+ // whenever either no longer matches the live upstream route.
972
+ release(upstream)
973
+ registration = undefined
942
974
  }
943
975
  }
944
- if (registered) {
976
+ if (registration !== undefined) {
945
977
  // Dead handle from a registry rebuild/reset: drop it and register a
946
978
  // fresh wrapper below.
947
979
  release(upstream)
@@ -961,7 +993,7 @@ export function installImageInputVariants(
961
993
  () => getConfig().imageInputVariants.hidden,
962
994
  ),
963
995
  )
964
- registrations.set(upstream, dispose)
996
+ registrations.set(upstream, { dispose, retryPolicyKey: upstreamRetryPolicyKey })
965
997
  stale.delete(upstream)
966
998
  } catch (error) {
967
999
  ctx.logger.warn(