dsh-vision-router 2.3.0 → 3.0.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 (105) hide show
  1. package/README.md +18 -3
  2. package/README.zh.md +16 -3
  3. package/docs/architecture/3.0-architecture-plan.md +252 -0
  4. package/docs/architecture/3.0-r0-contract-ledger.md +137 -0
  5. package/docs/architecture/3.0-r1-source-artifact-boundary.md +98 -0
  6. package/docs/architecture/3.0-r10-final-correctness-closure.md +207 -0
  7. package/docs/architecture/3.0-r11-adversarial-review.md +447 -0
  8. package/docs/architecture/3.0-r5-browser-go-no-go.md +165 -0
  9. package/docs/architecture/3.0-r5-browser-typescript-gate.md +105 -0
  10. package/docs/architecture/3.0-r6-compat-retirement.md +203 -0
  11. package/docs/architecture/3.0-r7-public-api.md +151 -0
  12. package/docs/architecture/3.0-r8-ci-release.md +248 -0
  13. package/docs/architecture/3.0-r9-final-audit.md +370 -0
  14. package/docs/architecture/dsh-support-window.md +13 -3
  15. package/docs/releases/v3.0.0.md +85 -0
  16. package/docs/releases/v3.0.1.md +77 -0
  17. package/entry.js +1 -1
  18. package/index.js +110 -71
  19. package/lib/agent-request-route-authority.js +1 -20
  20. package/lib/core-apply-lifecycle.js +37 -43
  21. package/lib/core-cache.js +87 -0
  22. package/lib/core-primitives.js +11 -144
  23. package/lib/core-vision-surface-domain.js +63 -0
  24. package/lib/core-vision-surface.js +11 -77
  25. package/lib/delegated-call-config.js +15 -24
  26. package/lib/doctor-cli.js +0 -0
  27. package/lib/doctor-runtime.js +4 -1
  28. package/lib/dsh-contract-compat.js +26 -230
  29. package/lib/dsh-host-capabilities.js +3 -9
  30. package/lib/dsh-settings-017-compat.js +17 -10
  31. package/lib/file-logger.js +8 -3
  32. package/lib/grounding-coordinate-runtime.js +1 -2
  33. package/lib/http-body-limit.js +97 -79
  34. package/lib/image-offload-compat.js +30 -21
  35. package/lib/image-resource-governor.js +210 -228
  36. package/lib/legacy-global-proxy-boundary.js +46 -3
  37. package/lib/live-model-client-prelude.js +12 -4
  38. package/lib/live-model-discovery.js +134 -43
  39. package/lib/local-vision-stabilizer.js +33 -18
  40. package/lib/maintenance-routes.js +1 -3
  41. package/lib/native-image-coexistence.js +15 -3
  42. package/lib/ollama-cold-start.js +87 -23
  43. package/lib/pi-ai-bridge-wire-compat.js +3 -2
  44. package/lib/proxy-dispatcher-pool.js +151 -92
  45. package/lib/proxy-routing.js +75 -51
  46. package/lib/proxy-url-compat.js +3 -10
  47. package/lib/public-config.js +23 -44
  48. package/lib/public-entry.d.ts +148 -0
  49. package/lib/public-entry.js +17 -4
  50. package/lib/replay-delegation.js +12 -7
  51. package/lib/replay-envelope-v2-compat.js +12 -7
  52. package/lib/runtime-composition.js +30 -10
  53. package/lib/screenshot-source-boundary.js +2 -2
  54. package/lib/session-affinity-runtime.js +113 -97
  55. package/lib/session-affinity.js +89 -76
  56. package/lib/session-event-history-compat.js +30 -0
  57. package/lib/session-query-readers.js +263 -0
  58. package/lib/session-surface-compat.js +30 -31
  59. package/lib/session-surface-policy-domain.js +85 -0
  60. package/lib/session-surface-policy.js +2 -111
  61. package/lib/session-turn-resolver.js +2 -6
  62. package/lib/session-vision-attachment-index.js +143 -0
  63. package/lib/session-vision-delegation-boundary.js +34 -0
  64. package/lib/session-vision-delegation.js +22 -0
  65. package/lib/session-vision-index.js +25 -107
  66. package/lib/session-vision-mode-authority.js +229 -192
  67. package/lib/session-vision-mode-boundary.js +65 -22
  68. package/lib/session-vision-policy-domain.js +304 -0
  69. package/lib/session-vision-policy.js +104 -0
  70. package/lib/session-vision-state.js +368 -378
  71. package/lib/settings-client-017-compat.js +16 -10
  72. package/lib/structured-flow-hardening.js +22 -4
  73. package/lib/tesseract-exec-compat.js +9 -1
  74. package/lib/turn-budget-context.js +47 -41
  75. package/lib/update-check.js +11 -2
  76. package/lib/vision-attachment-handle-runtime.js +176 -156
  77. package/lib/vision-backend-runtime-policy.js +16 -0
  78. package/lib/vision-background-benchmark.js +55 -6
  79. package/lib/vision-background-failure-policy.js +41 -41
  80. package/lib/vision-background-stop-store-core.js +217 -0
  81. package/lib/vision-background-stop-store.js +8 -179
  82. package/lib/vision-capability-benchmark.js +11 -61
  83. package/lib/vision-capability-evidence.js +35 -0
  84. package/lib/vision-capability-fingerprint.js +72 -0
  85. package/lib/vision-capability-identity.js +116 -61
  86. package/lib/vision-capability-router.js +13 -33
  87. package/lib/vision-chunk-assembler.js +95 -0
  88. package/lib/vision-diagnostics-routes.js +42 -4
  89. package/lib/vision-execution-order-apply.js +45 -55
  90. package/lib/vision-execution-order.js +19 -19
  91. package/lib/vision-execution-policy.js +12 -7
  92. package/lib/vision-model-registry.js +30 -17
  93. package/lib/vision-model-visibility-boundary-main.js +15 -16
  94. package/lib/vision-provider-transport.js +137 -147
  95. package/lib/vision-routing-authority.js +47 -48
  96. package/lib/vision-routing-product.js +26 -29
  97. package/lib/vision-routing-settings-prelude.js +8 -3
  98. package/lib/vision-runtime-performance-store.js +166 -0
  99. package/lib/vision-runtime-performance.js +13 -142
  100. package/lib/vision-tool-runtime-boundary.js +99 -30
  101. package/lib/web/dsh-settings-017-root-transport.js +27 -12
  102. package/lib/web/remote-settings-client.js +4 -7
  103. package/lib/web-capability-boundary.js +17 -7
  104. package/lib/wrapper-directory.js +14 -23
  105. package/package.json +37 -11
@@ -5,6 +5,11 @@ 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 {
9
+ currentLegacyGlobalProxyFetch,
10
+ legacyGlobalProxyPairFor,
11
+ runWithLegacyGlobalProxyScope,
12
+ } from './legacy-global-proxy-boundary.js'
8
13
  import { isLocalUiRequest } from './web-capability-boundary.js'
9
14
 
10
15
  export const LIVE_MODELS_PATH = '/_dsh/vision-router/live-models'
@@ -17,6 +22,10 @@ export const DEFAULT_MAX_MODELS_PER_PROVIDER = 2_000
17
22
 
18
23
  const SUPPORTED_DISCOVERY_PROTOCOLS = new Set(['openai-completions', 'openai-responses'])
19
24
 
25
+ const moduleFetch = typeof globalThis.fetch === 'function'
26
+ ? globalThis.fetch.bind(globalThis)
27
+ : undefined
28
+
20
29
  function nonEmpty(value) {
21
30
  return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
22
31
  }
@@ -162,19 +171,57 @@ async function saveCache(file, providers, fsOps) {
162
171
  await fsOps.rename(temporary, file)
163
172
  }
164
173
 
174
+ function plainProviderMap(value) {
175
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined
176
+ }
177
+
178
+ /**
179
+ * Host-side provider profiles have two possible homes on a real DSH host:
180
+ * - the settings service (some Host lines mirror a plugin's config there), and
181
+ * - the profile configuration owned by the Host config editor, which is where
182
+ * DSH 0.2.0-rc.2 keeps `llm-pi-ai.providers` (the Models page writes them
183
+ * back to the profile patch, never to a settings section).
184
+ * Reading only the settings section made live discovery a dead path on that
185
+ * Host: `configuredProviderTransports()` returned `[]` and no `/models` probe
186
+ * could ever run.
187
+ */
188
+ function configEditorProviders(ctx) {
189
+ try {
190
+ const editor = ctx?.get?.('configEditor')
191
+ if (editor === undefined || typeof editor.configuration !== 'function') return undefined
192
+ const rows = editor.configuration()
193
+ if (!Array.isArray(rows)) return undefined
194
+ const row = rows.find((candidate) => {
195
+ const options = candidate?.entry?.options
196
+ return options?.id === 'llm-pi-ai' || options?.name === '@deepseek-ai/dsh-llm-pi-ai'
197
+ })
198
+ if (row === undefined) return undefined
199
+ // The row exists, so an absent/empty providers map means "no providers are
200
+ // configured" — authoritative, which lets the cache drop removed entries.
201
+ // Only a missing row or an unavailable editor stays non-authoritative, so a
202
+ // transient read can never delete evidence.
203
+ const inherited = plainProviderMap(row.inherited?.providers) ?? {}
204
+ const override = plainProviderMap(row.override?.providers) ?? {}
205
+ return { ...inherited, ...override }
206
+ } catch {
207
+ return undefined
208
+ }
209
+ }
210
+
165
211
  function piProfilesState(ctx) {
166
212
  try {
167
213
  const settings = ctx?.get?.('settings')
168
- if (!settings || typeof settings.get !== 'function') return { authoritative: false, providers: {} }
169
- const value = settings.get('llm-pi-ai')
170
- const providers = value?.providers
171
- if (!providers || typeof providers !== 'object' || Array.isArray(providers)) {
172
- return { authoritative: false, providers: {} }
214
+ if (settings && typeof settings.get === 'function') {
215
+ const value = settings.get('llm-pi-ai')
216
+ const providers = plainProviderMap(value?.providers)
217
+ if (providers !== undefined) return { authoritative: true, providers }
173
218
  }
174
- return { authoritative: true, providers }
175
219
  } catch {
176
- return { authoritative: false, providers: {} }
220
+ // Fall through to the profile-configuration source below.
177
221
  }
222
+ const fromEditor = configEditorProviders(ctx)
223
+ if (fromEditor !== undefined) return { authoritative: true, providers: fromEditor }
224
+ return { authoritative: false, providers: {} }
178
225
  }
179
226
 
180
227
  function rawPiProfiles(ctx) {
@@ -190,6 +237,21 @@ function providerConfigurationToken(provider, raw) {
190
237
  ])
191
238
  }
192
239
 
240
+ // The boundary resolves its pair list from the live config; read the same shape so
241
+ // the scope we open is one the boundary will accept.
242
+ function liveProxyConfig(ctx, fallbackConfig = {}) {
243
+ try {
244
+ const settings = ctx?.get?.('settings')
245
+ const live = settings?.get?.('vision-router')
246
+ if (live !== null && typeof live === 'object' && !Array.isArray(live)) {
247
+ return { ...fallbackConfig, ...live }
248
+ }
249
+ } catch {
250
+ // Fall back to the composition config.
251
+ }
252
+ return fallbackConfig
253
+ }
254
+
193
255
  function visionProviderPriority(ctx, fallbackConfig = {}) {
194
256
  const preferred = new Set()
195
257
  const collect = (value) => {
@@ -350,7 +412,13 @@ function boundedError(error) {
350
412
 
351
413
  export function createLiveModelDiscoveryManager(ctx, options = {}) {
352
414
  const now = typeof options.now === 'function' ? options.now : Date.now
353
- const fetchImpl = typeof options.fetchImpl === 'function' ? options.fetchImpl : globalThis.fetch
415
+ // The proxy boundary installs its wrapper after apply() returns, so a capture
416
+ // taken here would keep the raw host fetch and silently ignore a configured
417
+ // `proxy` in the probes below. Ask the boundary for the fetch it currently owns
418
+ // instead of reading the process global at call time (L1-sealed-process-global);
419
+ // the module-load capture stays as the last-resort fallback.
420
+ const injectedFetch = typeof options.fetchImpl === 'function' ? options.fetchImpl : undefined
421
+ const resolveFetch = () => injectedFetch ?? currentLegacyGlobalProxyFetch() ?? moduleFetch
354
422
  const freshMs = Math.max(1_000, Number(options.freshMs) || DEFAULT_LIVE_MODEL_FRESH_MS)
355
423
  const staleMs = Math.max(freshMs, Number(options.staleMs) || DEFAULT_LIVE_MODEL_STALE_MS)
356
424
  const timeoutMs = Math.max(500, Number(options.timeoutMs) || DEFAULT_LIVE_MODEL_TIMEOUT_MS)
@@ -510,11 +578,15 @@ export function createLiveModelDiscoveryManager(ctx, options = {}) {
510
578
  try {
511
579
  const headers = { accept: 'application/json' }
512
580
  if (plan.apiKey !== undefined) headers.authorization = `Bearer ${plan.apiKey}`
513
- const response = await fetchImpl(listingURL(plan.baseURL), {
581
+ const probe = () => resolveFetch()(listingURL(plan.baseURL), {
514
582
  method: 'GET',
515
583
  headers,
516
584
  signal: controller.signal,
517
585
  })
586
+ const proxyPair = legacyGlobalProxyPairFor(liveProxyConfig(ctx, fallbackConfig), provider)
587
+ const response = proxyPair === undefined
588
+ ? await probe()
589
+ : await runWithLegacyGlobalProxyScope(proxyPair.provider, proxyPair.model, probe)
518
590
  if (!response?.ok) {
519
591
  const error = new Error(`provider model listing answered HTTP ${response?.status ?? 'unknown'}`)
520
592
  error.status = response?.status
@@ -673,42 +745,61 @@ export function installLiveModelDiscovery(ctx, options = {}) {
673
745
  // Event forwarding is an optimization; the request path still refreshes.
674
746
  }
675
747
 
676
- ctx?.inject?.(['webServer'], (webCtx) => {
677
- webCtx.effect(() => webCtx.webServer.register({
678
- kind: 'exact',
679
- path: LIVE_MODELS_PATH,
680
- handler: async (req, res) => {
681
- if (req.method !== 'GET') {
682
- res.setHeader('Allow', 'GET')
683
- sendJson(res, 405, { ok: false, error: 'method not allowed' })
684
- return
685
- }
686
- try {
687
- const url = new URL(req.url ?? LIVE_MODELS_PATH, 'http://localhost')
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'
693
- sendJson(res, 200, await manager.snapshot({ schedule }))
694
- } catch (error) {
695
- sendJson(res, 500, { ok: false, error: boundedError(error) })
696
- }
697
- },
698
- }), 'vision-router: live provider model discovery')
699
- })
748
+ let disposePromise
749
+ const disposeLifecycle = () => {
750
+ if (disposePromise) return disposePromise
751
+ lifecycleDisposed = true
752
+ if (startupTimer !== undefined) {
753
+ clearTimeout(startupTimer)
754
+ startupTimer = undefined
755
+ }
756
+ for (const dispose of disposers.splice(0)) {
757
+ try { dispose() } catch { /* best effort */ }
758
+ }
759
+ disposePromise = Promise.resolve(manager.dispose())
760
+ return disposePromise
761
+ }
700
762
 
763
+ // Own every timer/listener/manager resource before any later Host seam can
764
+ // throw. This keeps injection failure from stranding a live generation.
701
765
  try {
702
- ctx?.effect?.(() => () => {
703
- lifecycleDisposed = true
704
- if (startupTimer !== undefined) clearTimeout(startupTimer)
705
- for (const dispose of disposers) {
706
- try { dispose() } catch { /* best effort */ }
707
- }
708
- void manager.dispose()
709
- }, 'vision-router: live model discovery lifecycle')
710
- } catch {
711
- // Host service disposal still tears down the registered route.
766
+ ctx?.effect?.(
767
+ () => disposeLifecycle,
768
+ 'vision-router: live model discovery lifecycle',
769
+ )
770
+ } catch (error) {
771
+ void disposeLifecycle().catch(() => {})
772
+ throw error
773
+ }
774
+
775
+ try {
776
+ ctx?.inject?.(['webServer'], (webCtx) => {
777
+ webCtx.effect(() => webCtx.webServer.register({
778
+ kind: 'exact',
779
+ path: LIVE_MODELS_PATH,
780
+ handler: async (req, res) => {
781
+ if (req.method !== 'GET') {
782
+ res.setHeader('Allow', 'GET')
783
+ sendJson(res, 405, { ok: false, error: 'method not allowed' })
784
+ return
785
+ }
786
+ try {
787
+ const url = new URL(req.url ?? LIVE_MODELS_PATH, 'http://localhost')
788
+ // The snapshot itself is safe for an authenticated remote Settings UI,
789
+ // but refresh drives Host-side provider I/O and may resolve stored
790
+ // credentials. Preserve local ?refresh=1 behavior while making every
791
+ // remote GET a passive read of already-owned discovery state.
792
+ const schedule = isLocalUiRequest(req) && url.searchParams.get('refresh') !== '0'
793
+ sendJson(res, 200, await manager.snapshot({ schedule }))
794
+ } catch (error) {
795
+ sendJson(res, 500, { ok: false, error: boundedError(error) })
796
+ }
797
+ },
798
+ }), 'vision-router: live provider model discovery')
799
+ })
800
+ } catch (error) {
801
+ void disposeLifecycle().catch(() => {})
802
+ throw error
712
803
  }
713
804
  return manager
714
805
  }
@@ -43,7 +43,7 @@ export async function triggerDesktopScreenshotPermission({
43
43
  }
44
44
  }
45
45
 
46
- export function installLocalVisionStabilizer(ctx, config = {}, core) {
46
+ export function installLocalVisionStabilizer(ctx, config = {}, core, installOptions = {}) {
47
47
  config = normalizeRuntimeVisionConfig(config)
48
48
  if (!ctx || typeof ctx !== 'object') return { ctx, bootConfig: config }
49
49
 
@@ -180,7 +180,12 @@ export function installLocalVisionStabilizer(ctx, config = {}, core) {
180
180
  }
181
181
 
182
182
  const localMessages = async (options) => {
183
- const attachments = ctx.get('attachments')
183
+ let attachments
184
+ try {
185
+ attachments = typeof ctx?.get === 'function' ? ctx.get('attachments') : undefined
186
+ } catch {
187
+ attachments = undefined
188
+ }
184
189
  const current = actualConfig()
185
190
  const messages = []
186
191
  for (const message of options.messages ?? []) {
@@ -265,6 +270,7 @@ export function installLocalVisionStabilizer(ctx, config = {}, core) {
265
270
  const text = await core.callLocalBackend(local, await localMessages(options), {
266
271
  maxTokens: local.maxTokens ?? 4096,
267
272
  signal: options.signal,
273
+ providerTransport: installOptions?.providerTransport,
268
274
  resolveCredential,
269
275
  })
270
276
  if (text !== '') {
@@ -380,11 +386,8 @@ export function installLocalVisionStabilizer(ctx, config = {}, core) {
380
386
  const scope = target.register(namespace, schema, fixedOptions)
381
387
  if (namespace === 'vision-router') {
382
388
  rawScope = scope
383
- captureConfig(undefined, scope)
384
- syncScreenshot()
385
- // The Settings service is dynamic. Never keep its scope after the
386
- // owning injection fiber unloads; during the gap fall back to the
387
- // composition config, then bind the new scope on service restore.
389
+ // The Settings service is dynamic. Establish ownership before the
390
+ // scope can change screenshot state or install wrapped watchers.
388
391
  try {
389
392
  ownerCtx?.effect?.(
390
393
  () => () => {
@@ -396,8 +399,12 @@ export function installLocalVisionStabilizer(ctx, config = {}, core) {
396
399
  'vision-router: local settings scope lifecycle',
397
400
  )
398
401
  } catch {
399
- /* lifecycle hardening must not block Settings registration */
402
+ if (rawScope === scope) rawScope = undefined
403
+ resolvedConfig = config
404
+ return scope
400
405
  }
406
+ captureConfig(undefined, scope)
407
+ syncScreenshot()
401
408
  return wrapScope(scope)
402
409
  }
403
410
  return scope
@@ -459,7 +466,10 @@ export function installLocalVisionStabilizer(ctx, config = {}, core) {
459
466
  'vision-router: screenshot permission route lifecycle',
460
467
  )
461
468
  } catch {
462
- /* parent stabilizer cleanup remains a final fallback */
469
+ // webServer.register() is caller-owned in supported DSH Hosts. If this
470
+ // injected child cannot own the disposer, withdraw the route now rather
471
+ // than keeping it alive until the parent DVR generation eventually exits.
472
+ releaseScreenshotPermissionRoute(webServer, handle)
463
473
  }
464
474
  }
465
475
 
@@ -511,20 +521,25 @@ export function installLocalVisionStabilizer(ctx, config = {}, core) {
511
521
  }
512
522
  : undefined
513
523
 
524
+ const disposeLifecycle = () => {
525
+ rawScope = undefined
526
+ unmountScreenshot()
527
+ for (const [webServer, handle] of screenshotPermissionRoutes) {
528
+ releaseScreenshotPermissionRoute(webServer, handle)
529
+ }
530
+ screenshotPermissionRoutes.clear()
531
+ }
532
+
514
533
  try {
515
534
  ctx.effect?.(
516
- () => () => {
517
- rawScope = undefined
518
- unmountScreenshot()
519
- for (const [webServer, handle] of screenshotPermissionRoutes) {
520
- releaseScreenshotPermissionRoute(webServer, handle)
521
- }
522
- screenshotPermissionRoutes.clear()
523
- },
535
+ () => disposeLifecycle,
524
536
  'vision-router: local vision stabilizer',
525
537
  )
526
538
  } catch {
527
- /* cleanup registration is best effort */
539
+ // Routes/settings state may already have been admitted above. Roll them
540
+ // back immediately and do not expose an unowned wrapper to this generation.
541
+ disposeLifecycle()
542
+ return { ctx, bootConfig }
528
543
  }
529
544
 
530
545
  const stabilizedCtx = new Proxy(ctx, {
@@ -20,9 +20,7 @@ function json(res, status, body, headers = {}) {
20
20
  export function installVisionMaintenanceRoutes(ctx, options = {}) {
21
21
  if (!ctx || typeof ctx.inject !== 'function') return
22
22
  const logger = options.logger ?? ctx.logger
23
- const updateChecker = options.updateChecker ?? createCachedUpdateChecker({
24
- fetchImpl: (...args) => globalThis.fetch(...args),
25
- })
23
+ const updateChecker = options.updateChecker ?? createCachedUpdateChecker()
26
24
  const selfUpdatePlan = options.selfUpdatePlan ?? detectDshSelfUpdatePlan()
27
25
  const updateRunner = options.runUpdate ?? runDshPluginUpdate
28
26
  const tokenFactory = options.tokenFactory ?? (() => randomBytes(24).toString('base64url'))
@@ -385,7 +385,7 @@ export function currentSessionVisionPolicy() {
385
385
  return imageTurn.getStore()?.policy
386
386
  }
387
387
 
388
- function settingsServiceView(settings, state) {
388
+ function settingsServiceView(settings, state, ownerCtx) {
389
389
  if (!isObject(settings)) return settings
390
390
  return new Proxy(settings, {
391
391
  get(target, property) {
@@ -394,7 +394,19 @@ function settingsServiceView(settings, state) {
394
394
  if (typeof register !== 'function') return register
395
395
  return (namespace, ...args) => {
396
396
  const scope = register.call(target, namespace, ...args)
397
- if (namespace === 'vision-router') state.settingsScope = scope
397
+ if (namespace !== 'vision-router') return scope
398
+ try {
399
+ if (typeof ownerCtx?.effect !== 'function') return scope
400
+ ownerCtx.effect(
401
+ () => () => {
402
+ if (state.settingsScope === scope) state.settingsScope = undefined
403
+ },
404
+ 'vision-router: native image settings lifecycle',
405
+ )
406
+ } catch {
407
+ return scope
408
+ }
409
+ state.settingsScope = scope
398
410
  return scope
399
411
  }
400
412
  }
@@ -406,7 +418,7 @@ function settingsServiceView(settings, state) {
406
418
 
407
419
  function injectedContextView(child, state) {
408
420
  if (!isObject(child)) return child
409
- const settings = settingsServiceView(child.settings, state)
421
+ const settings = settingsServiceView(child.settings, state, child)
410
422
  return new Proxy(child, {
411
423
  get(target, property) {
412
424
  if (property === 'settings') return settings
@@ -10,6 +10,17 @@ export const OLLAMA_WARMUP_KEEP_ALIVE = '30m'
10
10
  export const OLLAMA_WARMUP_TIMEOUT_MS = 120000
11
11
  export const OLLAMA_PROBE_TIMEOUT_MS = 1500
12
12
 
13
+ // Capture the process fetch once, at module evaluation, exactly like the
14
+ // Router-owned provider transport. A per-call `(...args) =>
15
+ // globalThis.fetch(...args)` default would re-admit DVR's own later-installed
16
+ // process-wide fetch wrappers (legacy proxy boundary, i18n/pi-ai bridge) into
17
+ // Ollama warmup traffic, so the transport a warmup uses would depend on when
18
+ // the warmup happens to run. Probe-only traffic must stay on the same frozen
19
+ // transport as Router-owned provider calls.
20
+ const moduleFetch = typeof globalThis.fetch === 'function'
21
+ ? globalThis.fetch.bind(globalThis)
22
+ : undefined
23
+
13
24
  function errorText(error) {
14
25
  return error && error.message ? error.message : String(error)
15
26
  }
@@ -71,6 +82,32 @@ function timeoutSignal(ms, controllers) {
71
82
  }
72
83
  }
73
84
 
85
+ function abortReason(signal) {
86
+ return signal?.reason instanceof Error
87
+ ? signal.reason
88
+ : new Error('Ollama warmup aborted')
89
+ }
90
+
91
+ function awaitAbortable(task, signal) {
92
+ if (!signal || typeof signal.addEventListener !== 'function') return Promise.resolve(task)
93
+ if (signal.aborted) return Promise.reject(abortReason(signal))
94
+ return new Promise((resolve, reject) => {
95
+ let settled = false
96
+ const finish = (callback, value) => {
97
+ if (settled) return
98
+ settled = true
99
+ signal.removeEventListener('abort', onAbort)
100
+ callback(value)
101
+ }
102
+ const onAbort = () => finish(reject, abortReason(signal))
103
+ signal.addEventListener('abort', onAbort, { once: true })
104
+ Promise.resolve(task).then(
105
+ (value) => finish(resolve, value),
106
+ (error) => finish(reject, error),
107
+ )
108
+ })
109
+ }
110
+
74
111
  async function cancelBody(response) {
75
112
  try {
76
113
  await response?.body?.cancel?.()
@@ -112,7 +149,7 @@ async function readOllamaJson(response, label) {
112
149
  * that the model finished loading.
113
150
  */
114
151
  export function createOllamaWarmupManager({
115
- fetchImpl = (...args) => globalThis.fetch(...args),
152
+ fetchImpl = moduleFetch,
116
153
  logger,
117
154
  probeTimeoutMs = OLLAMA_PROBE_TIMEOUT_MS,
118
155
  warmupTimeoutMs = OLLAMA_WARMUP_TIMEOUT_MS,
@@ -121,6 +158,7 @@ export function createOllamaWarmupManager({
121
158
  const inFlight = new Map()
122
159
  const controllers = new Set()
123
160
  let disposed = false
161
+ let disposePromise
124
162
 
125
163
  const run = async (provider, reason, forceKeepAlive) => {
126
164
  const key = providerKey(provider)
@@ -136,12 +174,18 @@ export function createOllamaWarmupManager({
136
174
 
137
175
  const probe = timeoutSignal(probeTimeoutMs, controllers)
138
176
  try {
139
- const response = await fetchImpl(psUrl, { method: 'GET', signal: probe.signal })
177
+ const response = await awaitAbortable(
178
+ fetchImpl(psUrl, { method: 'GET', signal: probe.signal }),
179
+ probe.signal,
180
+ )
140
181
  if (!response?.ok) {
141
- await cancelBody(response)
182
+ await awaitAbortable(cancelBody(response), probe.signal)
142
183
  return { ok: false, reason: `probe-http-${response?.status ?? 'unknown'}` }
143
184
  }
144
- const payload = await readOllamaJson(response, 'Ollama /api/ps response')
185
+ const payload = await awaitAbortable(
186
+ readOllamaJson(response, 'Ollama /api/ps response'),
187
+ probe.signal,
188
+ )
145
189
  loaded = psContainsModel(payload, provider.model)
146
190
  if (loaded && forceKeepAlive !== true) {
147
191
  return {
@@ -159,7 +203,7 @@ export function createOllamaWarmupManager({
159
203
 
160
204
  const warm = timeoutSignal(warmupTimeoutMs, controllers)
161
205
  try {
162
- const response = await fetchImpl(generateUrl, {
206
+ const response = await awaitAbortable(fetchImpl(generateUrl, {
163
207
  method: 'POST',
164
208
  headers: { 'content-type': 'application/json' },
165
209
  body: JSON.stringify({
@@ -169,16 +213,19 @@ export function createOllamaWarmupManager({
169
213
  keep_alive: keepAlive,
170
214
  }),
171
215
  signal: warm.signal,
172
- })
216
+ }), warm.signal)
173
217
  if (!response?.ok) {
174
- await cancelBody(response)
218
+ await awaitAbortable(cancelBody(response), warm.signal)
175
219
  return { ok: false, reason: `warmup-http-${response?.status ?? 'unknown'}` }
176
220
  }
177
221
  // Do not cancel a successful preload body. A real HTTP fetch resolves as
178
222
  // soon as headers are available, so draining the bounded JSON response is
179
223
  // what proves Ollama finished loading the model before the inference
180
224
  // deadline is allowed to start.
181
- await readOllamaJson(response, 'Ollama preload response')
225
+ await awaitAbortable(
226
+ readOllamaJson(response, 'Ollama preload response'),
227
+ warm.signal,
228
+ )
182
229
  const durationMs = Date.now() - startedAt
183
230
  if (durationMs >= 1000) {
184
231
  try {
@@ -240,12 +287,17 @@ export function createOllamaWarmupManager({
240
287
  }
241
288
 
242
289
  const dispose = () => {
290
+ if (disposePromise) return disposePromise
243
291
  disposed = true
244
292
  for (const controller of controllers) {
245
293
  try { controller.abort(new Error('Vision Router disposed')) } catch { /* best effort */ }
246
294
  }
247
- controllers.clear()
248
- inFlight.clear()
295
+ const admitted = [...inFlight.values()]
296
+ disposePromise = Promise.allSettled(admitted).then(() => {
297
+ controllers.clear()
298
+ inFlight.clear()
299
+ })
300
+ return disposePromise
249
301
  }
250
302
 
251
303
  return { ensure, background, dispose }
@@ -291,7 +343,7 @@ export function installOllamaColdStartGuard(ctx, config = {}, core, options = {}
291
343
 
292
344
  let rawScope
293
345
  let scopeUnwatch
294
- const manager = options.manager || createOllamaWarmupManager({ logger: ctx.logger })
346
+ const manager = options.manager || createOllamaWarmupManager({ logger: options.logger ?? ctx.logger })
295
347
  const rawInject = typeof ctx.inject === 'function' ? ctx.inject.bind(ctx) : undefined
296
348
  const rawOn = typeof ctx.on === 'function' ? ctx.on.bind(ctx) : undefined
297
349
  const rawLlm = ctx.llm
@@ -325,14 +377,10 @@ export function installOllamaColdStartGuard(ctx, config = {}, core, options = {}
325
377
  try { scopeUnwatch() } catch { /* best effort */ }
326
378
  scopeUnwatch = undefined
327
379
  }
328
- backgroundWarm('settings-ready')
329
- if (typeof scope.watch === 'function') {
330
- try {
331
- scopeUnwatch = scope.watch(() => backgroundWarm('settings-changed'))
332
- } catch {
333
- scopeUnwatch = undefined
334
- }
335
- }
380
+
381
+ // Own this dynamic Settings generation before it can start warmup work or
382
+ // install a watcher. A rejected child fiber must never leave rawScope
383
+ // pointing at a dead Settings service.
336
384
  try {
337
385
  ownerCtx?.effect?.(
338
386
  () => () => {
@@ -345,7 +393,17 @@ export function installOllamaColdStartGuard(ctx, config = {}, core, options = {}
345
393
  'vision-router: Ollama warmup settings lifecycle',
346
394
  )
347
395
  } catch {
348
- /* lifecycle registration is best effort */
396
+ if (rawScope === scope) rawScope = undefined
397
+ return
398
+ }
399
+
400
+ backgroundWarm('settings-ready')
401
+ if (typeof scope.watch === 'function') {
402
+ try {
403
+ scopeUnwatch = scope.watch(() => backgroundWarm('settings-changed'))
404
+ } catch {
405
+ scopeUnwatch = undefined
406
+ }
349
407
  }
350
408
  }
351
409
 
@@ -455,18 +513,24 @@ export function installOllamaColdStartGuard(ctx, config = {}, core, options = {}
455
513
 
456
514
  try {
457
515
  ctx.effect?.(
458
- () => () => {
516
+ () => async () => {
459
517
  rawScope = undefined
460
518
  if (typeof scopeUnwatch === 'function') {
461
519
  try { scopeUnwatch() } catch { /* best effort */ }
462
520
  scopeUnwatch = undefined
463
521
  }
464
- manager.dispose()
522
+ await manager.dispose()
465
523
  },
466
524
  'vision-router: Ollama cold-start guard',
467
525
  )
468
526
  } catch {
469
- /* cleanup registration is best effort */
527
+ rawScope = undefined
528
+ if (typeof scopeUnwatch === 'function') {
529
+ try { scopeUnwatch() } catch { /* best effort */ }
530
+ scopeUnwatch = undefined
531
+ }
532
+ void Promise.resolve(manager.dispose()).catch(() => {})
533
+ return ctx
470
534
  }
471
535
 
472
536
  // Composition-level localOllama may already be enabled before Settings
@@ -342,8 +342,9 @@ export function installPiAiBridgeWireCompat(ctx, logger) {
342
342
  }
343
343
  try {
344
344
  ctx?.effect?.(() => cleanup, 'vision-router: pi-ai bridge wire compatibility')
345
- } catch {
346
- // Direct callers without Cordis lifecycle still receive the cleanup below.
345
+ } catch (error) {
346
+ cleanup()
347
+ throw error
347
348
  }
348
349
  return cleanup
349
350
  }