dsh-vision-router 2.1.6 → 2.2.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 +8 -14
- package/README.zh.md +8 -14
- package/cordis.patch.yml +14 -0
- package/docs/architecture/compat-inventory.md +1 -1
- package/docs/architecture/dsh-compatibility-matrix.md +4 -1
- package/docs/architecture/dsh-support-window.md +1 -2
- package/docs/releases/v2.1.7.md +13 -0
- package/docs/releases/v2.2.0.md +25 -0
- package/docs/remote-settings.md +2 -0
- package/entry.js +2 -0
- package/index.js +387 -77
- package/lib/artifact-boundary.js +11 -0
- package/lib/artifact-io.js +60 -7
- package/lib/catalog-corrections.js +5 -0
- package/lib/client.js +293 -48
- package/lib/core-primitives.js +122 -7
- package/lib/degraded-local-evidence.js +39 -0
- package/lib/dsh-contract-compat.js +202 -0
- package/lib/dsh-support-window.js +1 -1
- package/lib/image-offload-compat.js +38 -0
- package/lib/live-model-discovery.js +125 -15
- package/lib/local-vision-stabilizer.js +15 -6
- package/lib/official-deepseek-catalog.js +120 -0
- package/lib/ollama-cold-start.js +3 -15
- package/lib/remote-settings-bridge.js +28 -5
- package/lib/replay-delegation.js +68 -42
- package/lib/runtime-composition.js +15 -2
- package/lib/runtime-config-normalizer.js +271 -1
- package/lib/runtime-i18n-boundary.js +4 -13
- package/lib/runtime-i18n.js +2 -2
- package/lib/session-surface-compat.js +40 -0
- package/lib/session-turn-resolver.js +57 -0
- package/lib/session-vision-index.js +206 -93
- package/lib/session-vision-mode-boundary.js +83 -6
- package/lib/session-vision-runtime.js +8 -2
- package/lib/session-vision-state.js +0 -13
- package/lib/tesseract-exec-compat.js +16 -2
- package/lib/twin-image-capability-fallback.js +2 -11
- package/lib/vision-artifact-store.js +16 -3
- package/lib/vision-attachment-handle-runtime.js +9 -21
- package/lib/vision-backend-runtime-policy.js +7 -6
- package/lib/vision-background-stop-store.js +30 -20
- package/lib/vision-breaker-shadow-health.js +15 -5
- package/lib/vision-capability-probe.js +24 -14
- package/lib/vision-evidence-guidance.js +39 -0
- package/lib/vision-image-input-verdict.js +22 -7
- package/lib/vision-resilience.js +73 -1
- package/lib/web-capability-boundary.js +85 -22
- package/package.json +14 -7
|
@@ -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
|
|
165
|
+
function piProfilesState(ctx) {
|
|
165
166
|
try {
|
|
166
167
|
const settings = ctx?.get?.('settings')
|
|
167
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
591
|
+
const raw = reconcileConfiguredProviders()
|
|
495
592
|
const preferred = visionProviderPriority(ctx, fallbackConfig)
|
|
496
|
-
for (const provider of Object.keys(raw))
|
|
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
|
-
|
|
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) })
|
|
@@ -5,6 +5,7 @@ import path from 'node:path'
|
|
|
5
5
|
import { promisify } from 'node:util'
|
|
6
6
|
import { sameOriginRequest } from './adversarial-hardening.js'
|
|
7
7
|
import { normalizeRuntimeVisionConfig } from './runtime-config-normalizer.js'
|
|
8
|
+
import { isOffloadedImageBlock, offloadedImagePlaceholder } from './image-offload-compat.js'
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Narrow runtime guard around the local-vision features merged in #141.
|
|
@@ -177,6 +178,10 @@ export function installLocalVisionStabilizer(ctx, config = {}, core) {
|
|
|
177
178
|
const content = []
|
|
178
179
|
for (const block of message.content) {
|
|
179
180
|
if (block && block.type === 'image' && block.attachment) {
|
|
181
|
+
if (isOffloadedImageBlock(block)) {
|
|
182
|
+
content.push({ type: 'text', text: offloadedImagePlaceholder(block) })
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
180
185
|
if (!attachments || typeof attachments.readImage !== 'function') continue
|
|
181
186
|
try {
|
|
182
187
|
const stored = await attachments.readImage(block.attachment)
|
|
@@ -200,12 +205,16 @@ export function installLocalVisionStabilizer(ctx, config = {}, core) {
|
|
|
200
205
|
if (nested && nested.type === 'text' && typeof nested.text === 'string') {
|
|
201
206
|
parts.push(nested.text)
|
|
202
207
|
} else if (nested && nested.type === 'image') {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
208
|
+
if (isOffloadedImageBlock(nested)) {
|
|
209
|
+
parts.push(offloadedImagePlaceholder(nested))
|
|
210
|
+
} else {
|
|
211
|
+
const attachment = nested.attachment || {}
|
|
212
|
+
const id = attachment.attachmentId || attachment.id || 'unknown'
|
|
213
|
+
parts.push(
|
|
214
|
+
`[attached image: ${id}] this tool result contained an image; ` +
|
|
215
|
+
'inspect it with vision_describe (or re-read it with read_image)',
|
|
216
|
+
)
|
|
217
|
+
}
|
|
209
218
|
}
|
|
210
219
|
}
|
|
211
220
|
if (parts.length > 0) {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
const catalogStateByAdapter = new WeakMap()
|
|
2
|
+
|
|
3
|
+
export const OFFICIAL_DEEPSEEK_CATALOG_FRESH_MS = 30_000
|
|
4
|
+
export const OFFICIAL_DEEPSEEK_CATALOG_STALE_IF_ERROR_MS = 10 * 60_000
|
|
5
|
+
export const OFFICIAL_DEEPSEEK_CATALOG_FAILURE_BACKOFF_MS = 5_000
|
|
6
|
+
|
|
7
|
+
function catalogUnavailable(cause) {
|
|
8
|
+
const error = new Error('vision-router: the official DeepSeek catalog is temporarily unavailable')
|
|
9
|
+
error.code = 'OFFICIAL_CATALOG_UNAVAILABLE'
|
|
10
|
+
if (cause !== undefined) error.cause = cause
|
|
11
|
+
return error
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function normalizedModels(listed) {
|
|
15
|
+
if (!Array.isArray(listed)) {
|
|
16
|
+
throw new TypeError('official DeepSeek adapter listModels() did not return an array')
|
|
17
|
+
}
|
|
18
|
+
return Object.freeze(
|
|
19
|
+
listed
|
|
20
|
+
.filter((entry) => entry && typeof entry.id === 'string' && entry.id !== '')
|
|
21
|
+
.map((entry) => Object.freeze({ ...entry })),
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Process-local authority for the live official DeepSeek catalog.
|
|
27
|
+
*
|
|
28
|
+
* The special DeepSeek vision wrapper must not infer product identity from an
|
|
29
|
+
* adapter's permissive resolveModel() behavior. Membership therefore remains
|
|
30
|
+
* fail-closed to a catalog actually observed from the official adapter. A
|
|
31
|
+
* short fresh cache coalesces duplicate Core/replay reads; a bounded
|
|
32
|
+
* stale-if-error window keeps transient endpoint failures from taking down a
|
|
33
|
+
* long-lived session. Cold start and expired-cache failures stay fail-closed.
|
|
34
|
+
*
|
|
35
|
+
* State is keyed by adapter identity so Host adapter replacement cannot inherit
|
|
36
|
+
* another registration's catalog. Nothing is persisted across process restart:
|
|
37
|
+
* a previous process is not durable authority for a newly started Host.
|
|
38
|
+
*/
|
|
39
|
+
export async function getOfficialDeepSeekCatalog(adapter, {
|
|
40
|
+
provider = 'deepseek-official',
|
|
41
|
+
now = Date.now,
|
|
42
|
+
freshMs = OFFICIAL_DEEPSEEK_CATALOG_FRESH_MS,
|
|
43
|
+
staleIfErrorMs = OFFICIAL_DEEPSEEK_CATALOG_STALE_IF_ERROR_MS,
|
|
44
|
+
failureBackoffMs = OFFICIAL_DEEPSEEK_CATALOG_FAILURE_BACKOFF_MS,
|
|
45
|
+
} = {}) {
|
|
46
|
+
if (!adapter || typeof adapter.listModels !== 'function') {
|
|
47
|
+
throw catalogUnavailable()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let state = catalogStateByAdapter.get(adapter)
|
|
51
|
+
if (state === undefined) {
|
|
52
|
+
state = {
|
|
53
|
+
models: undefined,
|
|
54
|
+
fetchedAt: 0,
|
|
55
|
+
inFlight: undefined,
|
|
56
|
+
failure: undefined,
|
|
57
|
+
failedAt: 0,
|
|
58
|
+
}
|
|
59
|
+
catalogStateByAdapter.set(adapter, state)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const currentTime = Number(now())
|
|
63
|
+
const age =
|
|
64
|
+
state.models === undefined
|
|
65
|
+
? Number.POSITIVE_INFINITY
|
|
66
|
+
: Math.max(0, currentTime - state.fetchedAt)
|
|
67
|
+
if (state.models !== undefined && age <= Math.max(0, Number(freshMs) || 0)) {
|
|
68
|
+
return state.models
|
|
69
|
+
}
|
|
70
|
+
if (state.inFlight !== undefined) return state.inFlight
|
|
71
|
+
|
|
72
|
+
const failureAge =
|
|
73
|
+
state.failure === undefined
|
|
74
|
+
? Number.POSITIVE_INFINITY
|
|
75
|
+
: Math.max(0, currentTime - state.failedAt)
|
|
76
|
+
if (failureAge <= Math.max(0, Number(failureBackoffMs) || 0)) {
|
|
77
|
+
if (
|
|
78
|
+
state.models !== undefined
|
|
79
|
+
&& age <= Math.max(0, Number(staleIfErrorMs) || 0)
|
|
80
|
+
) {
|
|
81
|
+
return state.models
|
|
82
|
+
}
|
|
83
|
+
throw state.failure
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const refresh = (async () => {
|
|
87
|
+
try {
|
|
88
|
+
const listed = await adapter.listModels(provider)
|
|
89
|
+
const models = normalizedModels(listed)
|
|
90
|
+
state.models = models
|
|
91
|
+
state.fetchedAt = Number(now())
|
|
92
|
+
state.failure = undefined
|
|
93
|
+
state.failedAt = 0
|
|
94
|
+
return models
|
|
95
|
+
} catch (cause) {
|
|
96
|
+
const failureTime = Number(now())
|
|
97
|
+
const error = catalogUnavailable(cause)
|
|
98
|
+
state.failure = error
|
|
99
|
+
state.failedAt = failureTime
|
|
100
|
+
const staleAge =
|
|
101
|
+
state.models === undefined
|
|
102
|
+
? Number.POSITIVE_INFINITY
|
|
103
|
+
: Math.max(0, failureTime - state.fetchedAt)
|
|
104
|
+
if (
|
|
105
|
+
state.models !== undefined
|
|
106
|
+
&& staleAge <= Math.max(0, Number(staleIfErrorMs) || 0)
|
|
107
|
+
) {
|
|
108
|
+
return state.models
|
|
109
|
+
}
|
|
110
|
+
throw error
|
|
111
|
+
}
|
|
112
|
+
})()
|
|
113
|
+
|
|
114
|
+
state.inFlight = refresh
|
|
115
|
+
try {
|
|
116
|
+
return await refresh
|
|
117
|
+
} finally {
|
|
118
|
+
if (state.inFlight === refresh) state.inFlight = undefined
|
|
119
|
+
}
|
|
120
|
+
}
|
package/lib/ollama-cold-start.js
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
readResponseJsonBounded,
|
|
5
5
|
} from './http-body-limit.js'
|
|
6
6
|
import { stripTrailingSlashes } from './string-normalization.js'
|
|
7
|
+
import { blocksHaveRetainedImage } from './image-offload-compat.js'
|
|
7
8
|
|
|
8
9
|
export const OLLAMA_WARMUP_KEEP_ALIVE = '30m'
|
|
9
10
|
export const OLLAMA_WARMUP_TIMEOUT_MS = 120000
|
|
@@ -250,22 +251,9 @@ export function createOllamaWarmupManager({
|
|
|
250
251
|
return { ensure, background, dispose }
|
|
251
252
|
}
|
|
252
253
|
|
|
253
|
-
function messagesContainImage(messages,
|
|
254
|
+
function messagesContainImage(messages, _core) {
|
|
254
255
|
if (!Array.isArray(messages)) return false
|
|
255
|
-
return messages.some((message) =>
|
|
256
|
-
const content = message && Array.isArray(message.content) ? message.content : []
|
|
257
|
-
if (typeof core?.blocksHaveImage === 'function') {
|
|
258
|
-
try { return core.blocksHaveImage(content) } catch { /* fall through */ }
|
|
259
|
-
}
|
|
260
|
-
const stack = [...content]
|
|
261
|
-
while (stack.length > 0) {
|
|
262
|
-
const block = stack.pop()
|
|
263
|
-
if (!block || typeof block !== 'object') continue
|
|
264
|
-
if (block.type === 'image') return true
|
|
265
|
-
if (Array.isArray(block.content)) stack.push(...block.content)
|
|
266
|
-
}
|
|
267
|
-
return false
|
|
268
|
-
})
|
|
256
|
+
return messages.some((message) => blocksHaveRetainedImage(message?.content))
|
|
269
257
|
}
|
|
270
258
|
|
|
271
259
|
function configuredRows(config) {
|
|
@@ -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
|
|
|
@@ -171,6 +176,24 @@ export async function authorizeRemoteSettingsAfterRiskConfirmation(settings, pay
|
|
|
171
176
|
}
|
|
172
177
|
}
|
|
173
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Register the dedicated settings channel against the active DSH Connection
|
|
181
|
+
* trust contract. rc.8 requires a per-channel `trusted-host` policy. Newer
|
|
182
|
+
* Connection carriers expose `requestRejection()` and authenticate every
|
|
183
|
+
* dedicated RPC route before dispatch, while `rpc.handle()` accepts only the
|
|
184
|
+
* channel and handler. Feature-detect the carrier-owned trust fence rather
|
|
185
|
+
* than passing a security-looking argument that modern Hosts do not consume.
|
|
186
|
+
*/
|
|
187
|
+
export function registerVisionRouterRemoteSettingsChannel(connection, handler) {
|
|
188
|
+
if (!connection || typeof connection !== 'object' || !connection.rpc || typeof connection.rpc.handle !== 'function') {
|
|
189
|
+
throw new TypeError('Vision Router remote settings requires DSH Connection RPC handle()')
|
|
190
|
+
}
|
|
191
|
+
if (typeof connection.requestRejection === 'function') {
|
|
192
|
+
return connection.rpc.handle(REMOTE_SETTINGS_CHANNEL, handler)
|
|
193
|
+
}
|
|
194
|
+
return connection.rpc.handle(REMOTE_SETTINGS_CHANNEL, handler, { authority: 'trusted-host' })
|
|
195
|
+
}
|
|
196
|
+
|
|
174
197
|
export function createVisionRouterRemoteSettingsHandler(settings, logger) {
|
|
175
198
|
return async (endpoint, payload) => {
|
|
176
199
|
if (endpoint === 'describe') {
|
|
@@ -213,14 +236,14 @@ export function createVisionRouterRemoteSettingsHandler(settings, logger) {
|
|
|
213
236
|
}
|
|
214
237
|
|
|
215
238
|
export function installVisionRouterRemoteSettingsBridge(ctx, logger) {
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
239
|
+
// Declare the services this caller uses directly. Current DSH Connection
|
|
240
|
+
// accessors additionally trace their route owner through the Connection
|
|
241
|
+
// provider fiber; cordis.patch.yml therefore keeps that provider activated
|
|
242
|
+
// on webServer as a separate compatibility boundary (#478).
|
|
220
243
|
ctx.inject(['settings', 'connection', 'webServer'], (remoteCtx) => {
|
|
221
244
|
const handler = createVisionRouterRemoteSettingsHandler(remoteCtx.settings, logger ?? remoteCtx.logger)
|
|
222
245
|
remoteCtx.effect(
|
|
223
|
-
() => remoteCtx.connection
|
|
246
|
+
() => registerVisionRouterRemoteSettingsChannel(remoteCtx.connection, handler),
|
|
224
247
|
'vision-router: opt-in remote settings channel',
|
|
225
248
|
)
|
|
226
249
|
})
|