dsh-vision-router 2.3.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -3
- package/README.zh.md +12 -3
- package/docs/architecture/3.0-architecture-plan.md +252 -0
- package/docs/architecture/3.0-r0-contract-ledger.md +137 -0
- package/docs/architecture/3.0-r1-source-artifact-boundary.md +98 -0
- package/docs/architecture/3.0-r10-final-correctness-closure.md +207 -0
- package/docs/architecture/3.0-r11-adversarial-review.md +441 -0
- package/docs/architecture/3.0-r5-browser-go-no-go.md +165 -0
- package/docs/architecture/3.0-r5-browser-typescript-gate.md +105 -0
- package/docs/architecture/3.0-r6-compat-retirement.md +203 -0
- package/docs/architecture/3.0-r7-public-api.md +151 -0
- package/docs/architecture/3.0-r8-ci-release.md +248 -0
- package/docs/architecture/3.0-r9-final-audit.md +370 -0
- package/docs/architecture/dsh-support-window.md +5 -3
- package/docs/releases/v3.0.0.md +85 -0
- package/entry.js +1 -1
- package/index.js +108 -71
- package/lib/agent-request-route-authority.js +1 -20
- package/lib/core-apply-lifecycle.js +37 -43
- package/lib/core-cache.js +87 -0
- package/lib/core-primitives.js +3 -143
- package/lib/core-vision-surface-domain.js +63 -0
- package/lib/core-vision-surface.js +11 -77
- package/lib/delegated-call-config.js +15 -24
- package/lib/doctor-cli.js +0 -0
- package/lib/doctor-runtime.js +4 -1
- package/lib/dsh-contract-compat.js +26 -230
- package/lib/dsh-host-capabilities.js +3 -9
- package/lib/dsh-settings-017-compat.js +17 -10
- package/lib/file-logger.js +8 -3
- package/lib/grounding-coordinate-runtime.js +1 -2
- package/lib/http-body-limit.js +97 -79
- package/lib/image-offload-compat.js +30 -21
- package/lib/image-resource-governor.js +210 -228
- package/lib/legacy-global-proxy-boundary.js +46 -3
- package/lib/live-model-client-prelude.js +12 -4
- package/lib/live-model-discovery.js +134 -43
- package/lib/local-vision-stabilizer.js +31 -17
- package/lib/maintenance-routes.js +1 -3
- package/lib/native-image-coexistence.js +15 -3
- package/lib/ollama-cold-start.js +87 -23
- package/lib/pi-ai-bridge-wire-compat.js +3 -2
- package/lib/proxy-dispatcher-pool.js +151 -92
- package/lib/proxy-routing.js +75 -51
- package/lib/proxy-url-compat.js +3 -10
- package/lib/public-config.js +23 -44
- package/lib/public-entry.d.ts +148 -0
- package/lib/public-entry.js +17 -4
- package/lib/replay-delegation.js +12 -7
- package/lib/replay-envelope-v2-compat.js +12 -7
- package/lib/runtime-composition.js +22 -4
- package/lib/screenshot-source-boundary.js +2 -2
- package/lib/session-affinity-runtime.js +113 -97
- package/lib/session-affinity.js +89 -76
- package/lib/session-event-history-compat.js +30 -0
- package/lib/session-query-readers.js +263 -0
- package/lib/session-surface-compat.js +30 -31
- package/lib/session-surface-policy-domain.js +85 -0
- package/lib/session-surface-policy.js +2 -111
- package/lib/session-turn-resolver.js +2 -6
- package/lib/session-vision-attachment-index.js +143 -0
- package/lib/session-vision-delegation-boundary.js +34 -0
- package/lib/session-vision-delegation.js +22 -0
- package/lib/session-vision-index.js +25 -107
- package/lib/session-vision-mode-authority.js +229 -192
- package/lib/session-vision-mode-boundary.js +65 -22
- package/lib/session-vision-policy-domain.js +304 -0
- package/lib/session-vision-policy.js +104 -0
- package/lib/session-vision-state.js +368 -378
- package/lib/settings-client-017-compat.js +9 -7
- package/lib/structured-flow-hardening.js +22 -4
- package/lib/tesseract-exec-compat.js +9 -1
- package/lib/turn-budget-context.js +47 -41
- package/lib/update-check.js +11 -2
- package/lib/vision-attachment-handle-runtime.js +176 -156
- package/lib/vision-backend-runtime-policy.js +16 -0
- package/lib/vision-background-benchmark.js +41 -4
- package/lib/vision-background-failure-policy.js +41 -41
- package/lib/vision-background-stop-store-core.js +217 -0
- package/lib/vision-background-stop-store.js +8 -179
- package/lib/vision-capability-benchmark.js +11 -61
- package/lib/vision-capability-evidence.js +35 -0
- package/lib/vision-capability-fingerprint.js +72 -0
- package/lib/vision-capability-identity.js +116 -61
- package/lib/vision-capability-router.js +13 -33
- package/lib/vision-chunk-assembler.js +95 -0
- package/lib/vision-diagnostics-routes.js +34 -4
- package/lib/vision-execution-order-apply.js +45 -55
- package/lib/vision-execution-order.js +19 -19
- package/lib/vision-execution-policy.js +12 -7
- package/lib/vision-model-registry.js +30 -17
- package/lib/vision-provider-transport.js +137 -147
- package/lib/vision-routing-authority.js +47 -48
- package/lib/vision-routing-product.js +26 -29
- package/lib/vision-routing-settings-prelude.js +8 -3
- package/lib/vision-runtime-performance-store.js +166 -0
- package/lib/vision-runtime-performance.js +13 -142
- package/lib/vision-tool-runtime-boundary.js +99 -30
- package/lib/web/dsh-settings-017-root-transport.js +27 -12
- package/lib/web/remote-settings-client.js +4 -7
- package/lib/web-capability-boundary.js +17 -7
- package/lib/wrapper-directory.js +14 -23
- 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 (
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
}
|
|
@@ -180,7 +180,12 @@ export function installLocalVisionStabilizer(ctx, config = {}, core) {
|
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
const localMessages = async (options) => {
|
|
183
|
-
|
|
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 ?? []) {
|
|
@@ -380,11 +385,8 @@ export function installLocalVisionStabilizer(ctx, config = {}, core) {
|
|
|
380
385
|
const scope = target.register(namespace, schema, fixedOptions)
|
|
381
386
|
if (namespace === 'vision-router') {
|
|
382
387
|
rawScope = scope
|
|
383
|
-
|
|
384
|
-
|
|
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.
|
|
388
|
+
// The Settings service is dynamic. Establish ownership before the
|
|
389
|
+
// scope can change screenshot state or install wrapped watchers.
|
|
388
390
|
try {
|
|
389
391
|
ownerCtx?.effect?.(
|
|
390
392
|
() => () => {
|
|
@@ -396,8 +398,12 @@ export function installLocalVisionStabilizer(ctx, config = {}, core) {
|
|
|
396
398
|
'vision-router: local settings scope lifecycle',
|
|
397
399
|
)
|
|
398
400
|
} catch {
|
|
399
|
-
|
|
401
|
+
if (rawScope === scope) rawScope = undefined
|
|
402
|
+
resolvedConfig = config
|
|
403
|
+
return scope
|
|
400
404
|
}
|
|
405
|
+
captureConfig(undefined, scope)
|
|
406
|
+
syncScreenshot()
|
|
401
407
|
return wrapScope(scope)
|
|
402
408
|
}
|
|
403
409
|
return scope
|
|
@@ -459,7 +465,10 @@ export function installLocalVisionStabilizer(ctx, config = {}, core) {
|
|
|
459
465
|
'vision-router: screenshot permission route lifecycle',
|
|
460
466
|
)
|
|
461
467
|
} catch {
|
|
462
|
-
|
|
468
|
+
// webServer.register() is caller-owned in supported DSH Hosts. If this
|
|
469
|
+
// injected child cannot own the disposer, withdraw the route now rather
|
|
470
|
+
// than keeping it alive until the parent DVR generation eventually exits.
|
|
471
|
+
releaseScreenshotPermissionRoute(webServer, handle)
|
|
463
472
|
}
|
|
464
473
|
}
|
|
465
474
|
|
|
@@ -511,20 +520,25 @@ export function installLocalVisionStabilizer(ctx, config = {}, core) {
|
|
|
511
520
|
}
|
|
512
521
|
: undefined
|
|
513
522
|
|
|
523
|
+
const disposeLifecycle = () => {
|
|
524
|
+
rawScope = undefined
|
|
525
|
+
unmountScreenshot()
|
|
526
|
+
for (const [webServer, handle] of screenshotPermissionRoutes) {
|
|
527
|
+
releaseScreenshotPermissionRoute(webServer, handle)
|
|
528
|
+
}
|
|
529
|
+
screenshotPermissionRoutes.clear()
|
|
530
|
+
}
|
|
531
|
+
|
|
514
532
|
try {
|
|
515
533
|
ctx.effect?.(
|
|
516
|
-
() =>
|
|
517
|
-
rawScope = undefined
|
|
518
|
-
unmountScreenshot()
|
|
519
|
-
for (const [webServer, handle] of screenshotPermissionRoutes) {
|
|
520
|
-
releaseScreenshotPermissionRoute(webServer, handle)
|
|
521
|
-
}
|
|
522
|
-
screenshotPermissionRoutes.clear()
|
|
523
|
-
},
|
|
534
|
+
() => disposeLifecycle,
|
|
524
535
|
'vision-router: local vision stabilizer',
|
|
525
536
|
)
|
|
526
537
|
} catch {
|
|
527
|
-
|
|
538
|
+
// Routes/settings state may already have been admitted above. Roll them
|
|
539
|
+
// back immediately and do not expose an unowned wrapper to this generation.
|
|
540
|
+
disposeLifecycle()
|
|
541
|
+
return { ctx, bootConfig }
|
|
528
542
|
}
|
|
529
543
|
|
|
530
544
|
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
|
|
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
|
package/lib/ollama-cold-start.js
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
248
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
345
|
+
} catch (error) {
|
|
346
|
+
cleanup()
|
|
347
|
+
throw error
|
|
347
348
|
}
|
|
348
349
|
return cleanup
|
|
349
350
|
}
|