dsh-vision-router 2.1.6 → 2.1.7

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.
@@ -5,6 +5,7 @@ import process from 'node:process'
5
5
  import { resolveDshHome } from './doctor.js'
6
6
  import { MODEL_RESPONSE_MAX_BYTES, readResponseJsonBounded } from './http-body-limit.js'
7
7
  import { stripTrailingSlashes } from './string-normalization.js'
8
+ import { isLocalUiRequest } from './web-capability-boundary.js'
8
9
 
9
10
  export const LIVE_MODELS_PATH = '/_dsh/vision-router/live-models'
10
11
  export const LIVE_MODEL_CACHE_VERSION = 2
@@ -161,17 +162,34 @@ async function saveCache(file, providers, fsOps) {
161
162
  await fsOps.rename(temporary, file)
162
163
  }
163
164
 
164
- function rawPiProfiles(ctx) {
165
+ function piProfilesState(ctx) {
165
166
  try {
166
167
  const settings = ctx?.get?.('settings')
167
- const value = settings?.get?.('llm-pi-ai')
168
+ if (!settings || typeof settings.get !== 'function') return { authoritative: false, providers: {} }
169
+ const value = settings.get('llm-pi-ai')
168
170
  const providers = value?.providers
169
- return providers && typeof providers === 'object' && !Array.isArray(providers) ? providers : {}
171
+ if (!providers || typeof providers !== 'object' || Array.isArray(providers)) {
172
+ return { authoritative: false, providers: {} }
173
+ }
174
+ return { authoritative: true, providers }
170
175
  } catch {
171
- return {}
176
+ return { authoritative: false, providers: {} }
172
177
  }
173
178
  }
174
179
 
180
+ function rawPiProfiles(ctx) {
181
+ return piProfilesState(ctx).providers
182
+ }
183
+
184
+ function providerConfigurationToken(provider, raw) {
185
+ return JSON.stringify([
186
+ provider,
187
+ normalizeBaseURL(raw?.baseURL) ?? '',
188
+ nonEmpty(raw?.api) ?? '',
189
+ nonEmpty(raw?.apiKeyEnv) ?? '',
190
+ ])
191
+ }
192
+
175
193
  function visionProviderPriority(ctx, fallbackConfig = {}) {
176
194
  const preferred = new Set()
177
195
  const collect = (value) => {
@@ -349,15 +367,18 @@ export function createLiveModelDiscoveryManager(ctx, options = {}) {
349
367
  let providers = new Map()
350
368
  let version = 0
351
369
  let evidenceGeneration = 0
370
+ let configuredProviders
371
+ let configuredTokens = new Map()
372
+ let providerGenerations = new Map()
373
+ let providerGenerationSequence = 0
352
374
  let active = 0
353
375
  let disposed = false
354
376
  let saveTail = Promise.resolve()
355
377
  const inflight = new Map()
378
+ const inflightAuthority = new Map()
356
379
  const queued = new Map()
357
380
  const backoffUntil = new Map()
358
- const cacheReady = loadCache(cacheFile, fsOps).then((loaded) => {
359
- if (!disposed) providers = loaded
360
- })
381
+ const activeControllers = new Map()
361
382
 
362
383
  const persist = () => {
363
384
  saveTail = saveTail
@@ -366,6 +387,76 @@ export function createLiveModelDiscoveryManager(ctx, options = {}) {
366
387
  return saveTail
367
388
  }
368
389
 
390
+ const pruneUnconfiguredState = () => {
391
+ if (!(configuredProviders instanceof Set)) return false
392
+ let providerCacheChanged = false
393
+ for (const provider of [...providers.keys()]) {
394
+ if (configuredProviders.has(provider)) continue
395
+ providers.delete(provider)
396
+ providerCacheChanged = true
397
+ }
398
+ for (const provider of [...queued.keys()]) {
399
+ if (!configuredProviders.has(provider)) queued.delete(provider)
400
+ }
401
+ for (const provider of [...backoffUntil.keys()]) {
402
+ if (!configuredProviders.has(provider)) backoffUntil.delete(provider)
403
+ }
404
+ for (const [provider, controller] of activeControllers.entries()) {
405
+ if (configuredProviders.has(provider)) continue
406
+ try { controller.abort() } catch {}
407
+ }
408
+ if (providerCacheChanged) version += 1
409
+ return providerCacheChanged
410
+ }
411
+
412
+ const reconcileConfiguredProviders = () => {
413
+ const state = piProfilesState(ctx)
414
+ if (!state.authoritative) return state.providers
415
+ const next = new Set(Object.keys(state.providers))
416
+ const nextTokens = new Map([...next].map((provider) => [
417
+ provider,
418
+ providerConfigurationToken(provider, state.providers[provider]),
419
+ ]))
420
+ const nextGenerations = new Map()
421
+ for (const provider of next) {
422
+ const unchanged = configuredProviders instanceof Set &&
423
+ configuredProviders.has(provider) &&
424
+ configuredTokens.get(provider) === nextTokens.get(provider)
425
+ if (unchanged && providerGenerations.has(provider)) {
426
+ nextGenerations.set(provider, providerGenerations.get(provider))
427
+ } else {
428
+ providerGenerationSequence += 1
429
+ nextGenerations.set(provider, providerGenerationSequence)
430
+ }
431
+ }
432
+ configuredProviders = next
433
+ configuredTokens = nextTokens
434
+ providerGenerations = nextGenerations
435
+ if (pruneUnconfiguredState()) void persist()
436
+ return state.providers
437
+ }
438
+
439
+ const currentDiscoveryAuthority = (provider) => ({
440
+ providerGeneration: providerGenerations.get(provider) ?? 0,
441
+ evidenceGeneration,
442
+ })
443
+
444
+ const sameDiscoveryAuthority = (left, right) => (
445
+ left?.providerGeneration === right?.providerGeneration &&
446
+ left?.evidenceGeneration === right?.evidenceGeneration
447
+ )
448
+
449
+ const discoveryStillCurrent = (provider, authority) => (
450
+ sameDiscoveryAuthority(authority, currentDiscoveryAuthority(provider)) &&
451
+ (!(configuredProviders instanceof Set) || configuredProviders.has(provider))
452
+ )
453
+
454
+ const cacheReady = loadCache(cacheFile, fsOps).then((loaded) => {
455
+ if (disposed) return
456
+ providers = loaded
457
+ reconcileConfiguredProviders()
458
+ })
459
+
369
460
  const visibleEntry = (entry, at = now()) => {
370
461
  if (!entry || entry.routeMismatch === true || at - entry.discoveredAt > staleMs) return undefined
371
462
  const currentEvidence = entry.evidenceGeneration === evidenceGeneration
@@ -391,10 +482,10 @@ export function createLiveModelDiscoveryManager(ctx, options = {}) {
391
482
  }
392
483
  }
393
484
 
394
- const discover = async (provider) => {
485
+ const discover = async (provider, authorityAtStart) => {
395
486
  const plan = await providerPlan(ctx, provider)
396
- if (!plan.ok) return
397
- const generationAtStart = evidenceGeneration
487
+ if (!plan.ok || disposed || !discoveryStillCurrent(provider, authorityAtStart)) return
488
+ const generationAtStart = authorityAtStart.evidenceGeneration
398
489
  const previous = providers.get(provider)
399
490
  const at = now()
400
491
  const sameRoute = previous?.fingerprint === plan.fingerprint
@@ -414,6 +505,7 @@ export function createLiveModelDiscoveryManager(ctx, options = {}) {
414
505
  }
415
506
 
416
507
  const controller = new AbortController()
508
+ activeControllers.set(provider, controller)
417
509
  const timer = setTimeout(() => controller.abort(), timeoutMs)
418
510
  try {
419
511
  const headers = { accept: 'application/json' }
@@ -432,6 +524,7 @@ export function createLiveModelDiscoveryManager(ctx, options = {}) {
432
524
  label: `${provider} model listing`,
433
525
  })
434
526
  const models = normalizeOpenAIModelListing(body, { maxModels: options.maxModels })
527
+ if (disposed || !discoveryStillCurrent(provider, authorityAtStart)) return
435
528
  providers.set(provider, {
436
529
  provider,
437
530
  fingerprint: plan.fingerprint,
@@ -444,7 +537,7 @@ export function createLiveModelDiscoveryManager(ctx, options = {}) {
444
537
  version += 1
445
538
  void persist()
446
539
  } catch (error) {
447
- if (disposed) return
540
+ if (disposed || !discoveryStillCurrent(provider, authorityAtStart)) return
448
541
  const current = providers.get(provider)
449
542
  const message = boundedError(error)
450
543
  if (current) {
@@ -455,6 +548,7 @@ export function createLiveModelDiscoveryManager(ctx, options = {}) {
455
548
  logger?.debug?.('vision-router: live model discovery failed for %s: %s', provider, message)
456
549
  } finally {
457
550
  clearTimeout(timer)
551
+ if (activeControllers.get(provider) === controller) activeControllers.delete(provider)
458
552
  }
459
553
  }
460
554
 
@@ -470,14 +564,17 @@ export function createLiveModelDiscoveryManager(ctx, options = {}) {
470
564
  if (!picked) return
471
565
  queued.delete(picked.provider)
472
566
  active += 1
473
- const task = discover(picked.provider)
567
+ const authorityAtStart = currentDiscoveryAuthority(picked.provider)
568
+ const task = discover(picked.provider, authorityAtStart)
474
569
  .catch(() => {})
475
570
  .finally(() => {
476
571
  active = Math.max(0, active - 1)
477
572
  inflight.delete(picked.provider)
573
+ inflightAuthority.delete(picked.provider)
478
574
  pump()
479
575
  })
480
576
  inflight.set(picked.provider, task)
577
+ inflightAuthority.set(picked.provider, authorityAtStart)
481
578
  }
482
579
  }
483
580
 
@@ -491,9 +588,13 @@ export function createLiveModelDiscoveryManager(ctx, options = {}) {
491
588
  }
492
589
 
493
590
  function queueConfigured() {
494
- const raw = rawPiProfiles(ctx)
591
+ const raw = reconcileConfiguredProviders()
495
592
  const preferred = visionProviderPriority(ctx, fallbackConfig)
496
- for (const provider of Object.keys(raw)) queue(provider, preferred.has(provider) ? 0 : 10)
593
+ for (const provider of Object.keys(raw)) {
594
+ const activeAuthority = inflightAuthority.get(provider)
595
+ if (activeAuthority && sameDiscoveryAuthority(activeAuthority, currentDiscoveryAuthority(provider))) continue
596
+ queue(provider, preferred.has(provider) ? 0 : 10)
597
+ }
497
598
  }
498
599
 
499
600
  const hasModel = (provider, model) => {
@@ -524,7 +625,12 @@ export function createLiveModelDiscoveryManager(ctx, options = {}) {
524
625
  async dispose() {
525
626
  disposed = true
526
627
  queued.clear()
628
+ for (const controller of activeControllers.values()) {
629
+ try { controller.abort() } catch {}
630
+ }
527
631
  await Promise.allSettled([...inflight.values(), cacheReady, saveTail])
632
+ activeControllers.clear()
633
+ inflightAuthority.clear()
528
634
  },
529
635
  }
530
636
  }
@@ -579,7 +685,11 @@ export function installLiveModelDiscovery(ctx, options = {}) {
579
685
  }
580
686
  try {
581
687
  const url = new URL(req.url ?? LIVE_MODELS_PATH, 'http://localhost')
582
- const schedule = url.searchParams.get('refresh') !== '0'
688
+ // The snapshot itself is safe for an authenticated remote Settings UI,
689
+ // but refresh drives Host-side provider I/O and may resolve stored
690
+ // credentials. Preserve local ?refresh=1 behavior while making every
691
+ // remote GET a passive read of already-owned discovery state.
692
+ const schedule = isLocalUiRequest(req) && url.searchParams.get('refresh') !== '0'
583
693
  sendJson(res, 200, await manager.snapshot({ schedule }))
584
694
  } catch (error) {
585
695
  sendJson(res, 500, { ok: false, error: boundedError(error) })
@@ -3,6 +3,7 @@ import { installRemoteSettingsRiskConfirmationBridge } from './remote-settings-r
3
3
  import { installSettingsIaClientPrelude } from './settings-ia-client-prelude.js'
4
4
  import { installLegacySettingsMigration } from './settings-migration.js'
5
5
  import { installV2SettingsIaIntegration } from './v2-settings-ia-integration.js'
6
+ import { remoteConfigFieldAdmissionError } from './runtime-config-normalizer.js'
6
7
 
7
8
  const SETTINGS_NS = 'vision-router'
8
9
 
@@ -107,6 +108,10 @@ function validateMutation(payload, descriptor) {
107
108
  if (!mutable.has(field)) return { error: 'Vision Router setting ' + JSON.stringify(field) + ' is local-only' }
108
109
  const value = descriptor && descriptor.value
109
110
  if (typeof value !== 'object' || value === null || Array.isArray(value) || !Object.prototype.hasOwnProperty.call(value, field)) return { error: 'unknown Vision Router settings field ' + JSON.stringify(field) }
111
+ if (op.op === 'set') {
112
+ const budgetError = remoteConfigFieldAdmissionError(field, op.value)
113
+ if (budgetError) return { error: budgetError }
114
+ }
110
115
  return { ops: [op], expectedRevision }
111
116
  }
112
117
 
@@ -213,10 +218,10 @@ export function createVisionRouterRemoteSettingsHandler(settings, logger) {
213
218
  }
214
219
 
215
220
  export function installVisionRouterRemoteSettingsBridge(ctx, logger) {
216
- // DSH client-connection scopes rpc.handle() registrations to the caller
217
- // Context and mounts their WebRoute through owner.webServer. Keep webServer
218
- // on this exact injected fiber so strict Cordis service access in 0.1.5+
219
- // cannot turn remote-settings registration into a startup error.
221
+ // Declare the services this caller uses directly. Current DSH Connection
222
+ // accessors additionally trace their route owner through the Connection
223
+ // provider fiber; cordis.patch.yml therefore keeps that provider activated
224
+ // on webServer as a separate compatibility boundary (#478).
220
225
  ctx.inject(['settings', 'connection', 'webServer'], (remoteCtx) => {
221
226
  const handler = createVisionRouterRemoteSettingsHandler(remoteCtx.settings, logger ?? remoteCtx.logger)
222
227
  remoteCtx.effect(
@@ -12,7 +12,6 @@ const dynamicWrapperAdapters = new WeakMap()
12
12
  const NATIVE_DEEPSEEK_ROUTE = 'deepseek-official-native'
13
13
  const OFFICIAL_DEEPSEEK_ROUTE = 'deepseek-official'
14
14
  const VISION_HTTP_ROUTE = 'vision-http'
15
- const MAIN_WRAPPER_MODEL_IDS = new Set(['deepseek-v4-pro', 'deepseek-v4-flash'])
16
15
 
17
16
  // One entry per (sessionId, provider, model) tuple: a long-running dsh web
18
17
  // process would otherwise grow the reasoning-effort memory without bound as
@@ -153,45 +152,52 @@ function withoutReasoningEffort(options) {
153
152
  return rest
154
153
  }
155
154
 
156
- async function mainWrapperModels(ctx, fallbackWrapperRoute) {
155
+ async function mainWrapperCatalog(ctx) {
157
156
  // When the hidden native route is active the public stock DeepSeek row owns
158
157
  // the picker and the legacy wrapper is intentionally hidden.
159
- if (nativeDeepSeekActive(ctx)) return []
158
+ if (nativeDeepSeekActive(ctx)) return { adapter: undefined, models: [] }
160
159
  let registration
161
160
  try {
162
161
  registration = ctx?.llm?.registration(OFFICIAL_DEEPSEEK_ROUTE)
163
162
  } catch {
164
- return []
163
+ return { adapter: undefined, models: [] }
165
164
  }
166
165
  const adapter = registration && registration.adapter
167
- if (!adapter || typeof adapter.listModels !== 'function') return []
166
+ if (!adapter || typeof adapter.listModels !== 'function') {
167
+ return { adapter, models: [] }
168
+ }
168
169
  try {
169
170
  const listed = await adapter.listModels(OFFICIAL_DEEPSEEK_ROUTE)
170
- const route = currentWrapperRoute(ctx, fallbackWrapperRoute)
171
- return (Array.isArray(listed) ? listed : [])
172
- .filter((model) => model && MAIN_WRAPPER_MODEL_IDS.has(model.id))
173
- .map((model) => ({
174
- ...model,
175
- provider: route,
176
- inputModalities: ['text', 'image'],
177
- }))
171
+ return {
172
+ adapter,
173
+ models: (Array.isArray(listed) ? listed : []).filter(
174
+ (entry) => entry && isNonEmptyString(entry.id),
175
+ ),
176
+ }
178
177
  } catch {
179
- return []
178
+ return { adapter, models: [] }
180
179
  }
181
180
  }
182
181
 
182
+ async function mainWrapperModels(ctx, fallbackWrapperRoute) {
183
+ const { models } = await mainWrapperCatalog(ctx)
184
+ const route = currentWrapperRoute(ctx, fallbackWrapperRoute)
185
+ return models.map((model) => ({
186
+ ...model,
187
+ provider: route,
188
+ inputModalities: ['text', 'image'],
189
+ }))
190
+ }
191
+
183
192
  async function mainWrapperModel(ctx, fallbackWrapperRoute, model, signal) {
184
- // Composite legacy vision-chain entries are not DeepSeek models; leave them
185
- // to the core wrapper's existing resolver.
186
- if (!MAIN_WRAPPER_MODEL_IDS.has(model) || nativeDeepSeekActive(ctx)) return undefined
187
- let registration
188
- try {
189
- registration = ctx?.llm?.registration(OFFICIAL_DEEPSEEK_ROUTE)
190
- } catch {
191
- return undefined
192
- }
193
- const adapter = registration && registration.adapter
193
+ // The special wrapper mirrors the *live official DeepSeek catalog* rather
194
+ // than a frozen list of model ids. DSH stable can add a new default model
195
+ // without requiring a Vision Router release, while membership still fails
196
+ // closed to the official provider and cannot follow textProvider/relay rows.
197
+ if (!isNonEmptyString(model) || nativeDeepSeekActive(ctx)) return undefined
198
+ const { adapter, models } = await mainWrapperCatalog(ctx)
194
199
  if (!adapter || typeof adapter.resolveModel !== 'function') return undefined
200
+ if (!models.some((entry) => entry.id === model)) return undefined
195
201
  const base = await adapter.resolveModel(OFFICIAL_DEEPSEEK_ROUTE, model, signal)
196
202
  return {
197
203
  ...base,
@@ -246,6 +252,7 @@ function dynamicWrapperAdapter(adapter, ctx, fallbackWrapperRoute) {
246
252
  const pinned = await mainWrapperModels(ctx, fallbackWrapperRoute)
247
253
  if (typeof original !== 'function') return pinned
248
254
  const route = currentWrapperRoute(ctx, fallbackWrapperRoute)
255
+ const pinnedIds = new Set(pinned.map((model) => model.id))
249
256
  let composites = []
250
257
  try {
251
258
  const listed = await original.apply(target, args)
@@ -254,14 +261,13 @@ function dynamicWrapperAdapter(adapter, ctx, fallbackWrapperRoute) {
254
261
  // ("provider/model(视觉)", published when whole-turn routing is
255
262
  // enabled) so the legacy routing mode keeps its picker entries.
256
263
  // These rows are structurally composite ids (`provider/model`);
257
- // any DeepSeek mirror — and any other row the core might emit —
258
- // is dropped here: normal DeepSeek models must keep coming from
259
- // the pinned official/native identity above, never from the
260
- // possibly-stale textProvider.
264
+ // any official DeepSeek mirror — and any other row the core may
265
+ // emit — is dropped here. Normal DeepSeek models come only from
266
+ // the live official catalog above, never from textProvider.
261
267
  composites = listed.filter(
262
268
  (row) => row
263
269
  && row.provider === route
264
- && !MAIN_WRAPPER_MODEL_IDS.has(row.id)
270
+ && !pinnedIds.has(row.id)
265
271
  && String(row.id ?? '').includes('/'),
266
272
  )
267
273
  }