dsh-vision-router 2.1.7 → 2.2.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.
- package/README.md +8 -14
- package/README.zh.md +8 -14
- 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.2.0.md +25 -0
- package/docs/releases/v2.2.1.md +22 -0
- package/docs/remote-settings.md +2 -0
- package/entry.js +2 -0
- package/index.js +355 -64
- 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 +22 -17
- package/lib/core-primitives.js +126 -5
- package/lib/degraded-local-evidence.js +39 -0
- package/lib/dsh-contract-compat.js +225 -0
- package/lib/dsh-support-window.js +1 -1
- package/lib/fetch-wrapper-lifecycle.js +56 -0
- package/lib/image-offload-compat.js +38 -0
- package/lib/legacy-global-proxy-boundary.js +4 -3
- 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/pi-ai-bridge-wire-compat.js +4 -3
- package/lib/pixel-diff-stream.js +12 -6
- package/lib/remote-settings-bridge.js +19 -1
- package/lib/replay-delegation.js +41 -21
- package/lib/runtime-composition.js +15 -2
- 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 +438 -97
- 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-breaker-shadow-health.js +26 -6
- package/lib/vision-evidence-guidance.js +39 -0
- package/lib/vision-resilience.js +73 -1
- package/package.json +12 -7
|
@@ -26,6 +26,220 @@ const VISION_ROUTER_NORMALIZED_BYTES = LEGACY_VISION_ATTACHMENT_BYTES
|
|
|
26
26
|
const HOST_CAPABILITIES_PATH = '/_dsh/vision-router/host-capabilities'
|
|
27
27
|
const capabilityRouteInstalls = new WeakSet()
|
|
28
28
|
|
|
29
|
+
function sessionQueryOf(ctx) {
|
|
30
|
+
if (!ctx || typeof ctx !== 'object') return undefined
|
|
31
|
+
try {
|
|
32
|
+
if (typeof ctx.get === 'function') {
|
|
33
|
+
const query = ctx.get('sessionQuery')
|
|
34
|
+
if (query && typeof query === 'object') return query
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
// A partial/legacy Host may not expose SessionQuery through ctx.get().
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const query = ctx.sessionQuery
|
|
41
|
+
return query && typeof query === 'object' ? query : undefined
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build the narrow bounded Session event reader used by durable surface repair.
|
|
49
|
+
* DSH rc.8+ exposes `sessionQuery.readEvent({ sessionId, seq })`; resolve the
|
|
50
|
+
* service at call time so Host service replacement/hot reload stays visible.
|
|
51
|
+
* Missing capability is explicit and lets isolated/legacy callers use their
|
|
52
|
+
* existing Session-local compatibility fallback. Once the Host advertises the
|
|
53
|
+
* reader, operational failures propagate instead of silently falling back to
|
|
54
|
+
* deprecated synchronous Session history.
|
|
55
|
+
*/
|
|
56
|
+
export function createSessionEventReader(ctx) {
|
|
57
|
+
return async function readSessionEvent(session, seq) {
|
|
58
|
+
const sessionId = session?.id
|
|
59
|
+
if (sessionId === undefined || sessionId === null) return { supported: false }
|
|
60
|
+
if (!Number.isSafeInteger(seq) || seq < 0 || Object.is(seq, -0)) {
|
|
61
|
+
throw new TypeError(`session event seq must be a non-negative safe integer, got ${String(seq)}`)
|
|
62
|
+
}
|
|
63
|
+
const query = sessionQueryOf(ctx)
|
|
64
|
+
if (!query || typeof query.readEvent !== 'function') return { supported: false }
|
|
65
|
+
const window = await query.readEvent({ sessionId, seq })
|
|
66
|
+
const event = window?.target
|
|
67
|
+
if (!event || typeof event !== 'object') {
|
|
68
|
+
throw new Error(`sessionQuery.readEvent returned no target for seq ${seq}`)
|
|
69
|
+
}
|
|
70
|
+
if (event.seq !== undefined && event.seq !== seq) {
|
|
71
|
+
throw new Error(`sessionQuery.readEvent returned seq ${String(event.seq)} for requested seq ${seq}`)
|
|
72
|
+
}
|
|
73
|
+
return { supported: true, event }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
const SESSION_EVENT_TAIL_INITIAL_AFTER = 50
|
|
79
|
+
const SESSION_EVENT_TAIL_MAX_EVENTS = 4096
|
|
80
|
+
const SESSION_EVENT_TAIL_MAX_READS = 128
|
|
81
|
+
|
|
82
|
+
function sessionQueryCode(error) {
|
|
83
|
+
return typeof error?.code === 'string' ? error.code : undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Read raw Session events strictly after one known committed seq through the
|
|
88
|
+
* Host-owned bounded `sessionQuery.readEvent()` window contract.
|
|
89
|
+
*
|
|
90
|
+
* The reader adapts to deployments that lower SessionQuery's configurable
|
|
91
|
+
* `readWindowMax`, never materializes the full Session log, and caps both
|
|
92
|
+
* events and Host reads. Missing capability is explicit. Operational failures
|
|
93
|
+
* from an advertised capability propagate to the caller; a cap is reported as
|
|
94
|
+
* `truncated` so routing can fail safe without a synchronous-history fallback.
|
|
95
|
+
*/
|
|
96
|
+
export function createSessionEventTailReader(ctx) {
|
|
97
|
+
let afterHint = SESSION_EVENT_TAIL_INITIAL_AFTER
|
|
98
|
+
|
|
99
|
+
const readWindow = async (query, sessionId, seq) => {
|
|
100
|
+
let after = afterHint
|
|
101
|
+
while (true) {
|
|
102
|
+
try {
|
|
103
|
+
const request = after > 0 ? { sessionId, seq, after } : { sessionId, seq }
|
|
104
|
+
const window = await query.readEvent(request)
|
|
105
|
+
afterHint = after
|
|
106
|
+
return { window, after }
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (sessionQueryCode(error) !== 'SESSION_QUERY_INVALID_WINDOW' || after <= 0) throw error
|
|
109
|
+
after = after <= 1 ? 0 : Math.floor(after / 2)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return async function readSessionEventTail(session, anchorSeq, options = {}) {
|
|
115
|
+
const sessionId = session?.id
|
|
116
|
+
if (sessionId === undefined || sessionId === null) return { supported: false }
|
|
117
|
+
if (!Number.isSafeInteger(anchorSeq) || anchorSeq < 0 || Object.is(anchorSeq, -0)) {
|
|
118
|
+
throw new TypeError(`session tail anchor seq must be a non-negative safe integer, got ${String(anchorSeq)}`)
|
|
119
|
+
}
|
|
120
|
+
const query = sessionQueryOf(ctx)
|
|
121
|
+
if (!query || typeof query.readEvent !== 'function') return { supported: false }
|
|
122
|
+
|
|
123
|
+
const collect = options?.collect !== false
|
|
124
|
+
const events = []
|
|
125
|
+
let capturedThroughSeq = anchorSeq
|
|
126
|
+
let cursor = anchorSeq
|
|
127
|
+
let inspected = 0
|
|
128
|
+
let reads = 0
|
|
129
|
+
|
|
130
|
+
while (reads < SESSION_EVENT_TAIL_MAX_READS) {
|
|
131
|
+
let result
|
|
132
|
+
try {
|
|
133
|
+
result = await readWindow(query, sessionId, cursor)
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (sessionQueryCode(error) === 'SESSION_QUERY_EVENT_NOT_FOUND' && cursor > anchorSeq) {
|
|
136
|
+
return { supported: true, events, capturedThroughSeq, truncated: false }
|
|
137
|
+
}
|
|
138
|
+
throw error
|
|
139
|
+
}
|
|
140
|
+
reads += 1
|
|
141
|
+
|
|
142
|
+
const window = result.window
|
|
143
|
+
const target = window?.target
|
|
144
|
+
if (!target || typeof target !== 'object' || target.seq !== cursor) {
|
|
145
|
+
throw new Error(`sessionQuery.readEvent returned an invalid target for seq ${cursor}`)
|
|
146
|
+
}
|
|
147
|
+
if (!Array.isArray(window.events) || window.events.length === 0) {
|
|
148
|
+
throw new Error(`sessionQuery.readEvent returned no event window for seq ${cursor}`)
|
|
149
|
+
}
|
|
150
|
+
const windowEvents = window.events
|
|
151
|
+
let expectedSeq = cursor
|
|
152
|
+
let endSeq = cursor
|
|
153
|
+
for (const event of windowEvents) {
|
|
154
|
+
const seq = event?.seq
|
|
155
|
+
if (!Number.isSafeInteger(seq) || seq !== expectedSeq) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`sessionQuery.readEvent returned non-contiguous seq ${String(seq)}; expected ${expectedSeq}`,
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
expectedSeq += 1
|
|
161
|
+
endSeq = seq
|
|
162
|
+
if (seq <= capturedThroughSeq) continue
|
|
163
|
+
capturedThroughSeq = seq
|
|
164
|
+
inspected += 1
|
|
165
|
+
if (collect) events.push(event)
|
|
166
|
+
if (inspected >= SESSION_EVENT_TAIL_MAX_EVENTS) {
|
|
167
|
+
return { supported: true, events, capturedThroughSeq, truncated: true }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (Number.isSafeInteger(window?.endSeq) && window.endSeq !== endSeq) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`sessionQuery.readEvent returned endSeq ${String(window.endSeq)} for window ending at ${endSeq}`,
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (result.after > 0) {
|
|
177
|
+
if (endSeq <= cursor || windowEvents.length < result.after + 1) {
|
|
178
|
+
return { supported: true, events, capturedThroughSeq: Math.max(capturedThroughSeq, endSeq), truncated: false }
|
|
179
|
+
}
|
|
180
|
+
cursor = endSeq
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// `readWindowMax: 0` is a valid Host configuration. Walk exact seqs and
|
|
185
|
+
// use the typed EVENT_NOT_FOUND result as the logical tail sentinel.
|
|
186
|
+
cursor += 1
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { supported: true, events, capturedThroughSeq, truncated: true }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Build the asynchronous Session-log reader used by cold attachment recovery
|
|
196
|
+
* and batched surface repair. Prefer SessionQuery's observation lease when it
|
|
197
|
+
* exists: one exact live cut materializes at most one immutable event snapshot,
|
|
198
|
+
* rather than cloning the whole live corpus once per requested event (#524).
|
|
199
|
+
* Hosts without that capability retain the released readSession() contract.
|
|
200
|
+
*/
|
|
201
|
+
export function createSessionLogReader(ctx) {
|
|
202
|
+
return async function readSessionLog(session) {
|
|
203
|
+
const sessionId = session?.id
|
|
204
|
+
if (sessionId === undefined || sessionId === null) return { supported: false }
|
|
205
|
+
const query = sessionQueryOf(ctx)
|
|
206
|
+
if (!query) return { supported: false }
|
|
207
|
+
|
|
208
|
+
if (typeof query.observeSession === 'function') {
|
|
209
|
+
let observation
|
|
210
|
+
try {
|
|
211
|
+
observation = await query.observeSession(sessionId, { projectionMode: 'none' })
|
|
212
|
+
if (!observation || !Array.isArray(observation.events)) {
|
|
213
|
+
throw new Error('sessionQuery.observeSession returned no event log')
|
|
214
|
+
}
|
|
215
|
+
const observedId = observation.header?.id
|
|
216
|
+
if (observedId !== undefined && observedId !== sessionId) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
`sessionQuery.observeSession returned session ${String(observedId)} for requested ${String(sessionId)}`,
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
return { supported: true, events: observation.events }
|
|
222
|
+
} finally {
|
|
223
|
+
const dispose = observation?.[Symbol.dispose]
|
|
224
|
+
if (typeof dispose === 'function') dispose.call(observation)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (typeof query.readSession !== 'function') return { supported: false }
|
|
229
|
+
const snapshot = await query.readSession(sessionId)
|
|
230
|
+
if (!snapshot || !Array.isArray(snapshot.events)) {
|
|
231
|
+
throw new Error('sessionQuery.readSession returned no event log')
|
|
232
|
+
}
|
|
233
|
+
const observedId = snapshot.session?.id
|
|
234
|
+
if (observedId !== undefined && observedId !== sessionId) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`sessionQuery.readSession returned session ${String(observedId)} for requested ${String(sessionId)}`,
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
return { supported: true, events: snapshot.events }
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
29
243
|
function attachmentStoreOf(ctx) {
|
|
30
244
|
if (!ctx || typeof ctx !== 'object') return undefined
|
|
31
245
|
try {
|
|
@@ -111,6 +325,17 @@ export function hasBatchAttachmentContract(ctx) {
|
|
|
111
325
|
return !!store && typeof store.saveImages === 'function'
|
|
112
326
|
}
|
|
113
327
|
|
|
328
|
+
/**
|
|
329
|
+
* DSH Hosts on the canonical batch-attachment contract also keep the official
|
|
330
|
+
* DeepSeek provider Host-owned. Later generations add still more request-local
|
|
331
|
+
* file/image-access seams, so DVR may wrap this provider but must never rebuild
|
|
332
|
+
* or resurrect it. Keep this semantic fact
|
|
333
|
+
* centralized so Core does not grow a second Host-generation detector.
|
|
334
|
+
*/
|
|
335
|
+
export function hostOwnsOfficialDeepSeekProvider(ctx) {
|
|
336
|
+
return hasBatchAttachmentContract(ctx)
|
|
337
|
+
}
|
|
338
|
+
|
|
114
339
|
function exactAlphaNormalizationDefaults(policy) {
|
|
115
340
|
return !!policy && typeof policy === 'object' &&
|
|
116
341
|
policy.maxPixels === DSH_ALPHA_DEFAULT_NORMALIZED_PIXELS &&
|
|
@@ -6,7 +6,7 @@ export const DSH_SUPPORT_WINDOW = Object.freeze({
|
|
|
6
6
|
|
|
7
7
|
export const DSH_VERIFICATION_EVIDENCE = Object.freeze({
|
|
8
8
|
exactStable: '0.1.5-rc.2',
|
|
9
|
-
exactPreview: '0.1.
|
|
9
|
+
exactPreview: '0.1.6-alpha.1',
|
|
10
10
|
stableCanaryDistTag: 'latest',
|
|
11
11
|
previewCanaryDistTag: 'alpha',
|
|
12
12
|
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Lifecycle metadata only: no native fetch snapshot, dispatcher, or egress
|
|
2
|
+
// policy is retained here. Weak keys let abandoned wrapper chains be collected.
|
|
3
|
+
const installations = new WeakMap()
|
|
4
|
+
|
|
5
|
+
function livePreviousDescriptor(descriptor) {
|
|
6
|
+
let previous = descriptor
|
|
7
|
+
while (previous && Object.hasOwn(previous, 'value')) {
|
|
8
|
+
const installation = installations.get(previous.value)
|
|
9
|
+
if (!installation?.disposed) break
|
|
10
|
+
previous = installation.previous
|
|
11
|
+
}
|
|
12
|
+
return previous
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Preserve a live accessor-owned pipeline, including middleware recomposition. */
|
|
16
|
+
export function captureFetchDelegate(target = globalThis) {
|
|
17
|
+
const descriptor = Object.getOwnPropertyDescriptor(target, 'fetch')
|
|
18
|
+
const current = target.fetch
|
|
19
|
+
if (typeof current !== 'function' || typeof descriptor?.get !== 'function') return current
|
|
20
|
+
return (input, init) => Reflect.apply(Reflect.apply(descriptor.get, target, []), target, [input, init])
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Publish an already constructed fetch wrapper without calling a foreign
|
|
25
|
+
* accessor setter. Assignment is unsafe: a pipeline getter may return a
|
|
26
|
+
* function whose setter adopts our wrapper as its own delegate (#519).
|
|
27
|
+
*
|
|
28
|
+
* The caller captures the CURRENT Host chain at installation, never at module
|
|
29
|
+
* load, and disables its own behavior before restoring. Later Host replacements
|
|
30
|
+
* remain authoritative. Restore the exact previous descriptor only while our
|
|
31
|
+
* data property still owns the surface; never feed a wrapper back to a setter.
|
|
32
|
+
* Non-configurable accessors cannot be composed this way and fail before any
|
|
33
|
+
* mutation rather than silently bypassing a Host policy or explicit proxy.
|
|
34
|
+
*/
|
|
35
|
+
export function installFetchWrapper(wrapped, target = globalThis) {
|
|
36
|
+
if (typeof wrapped !== 'function') throw new TypeError('fetch wrapper must be a function')
|
|
37
|
+
const previous = Object.getOwnPropertyDescriptor(target, 'fetch')
|
|
38
|
+
if (previous && Object.hasOwn(previous, 'value') && previous.value === wrapped) return () => {}
|
|
39
|
+
const installed = previous && Object.hasOwn(previous, 'value')
|
|
40
|
+
? { ...previous, value: wrapped }
|
|
41
|
+
: { configurable: true, enumerable: previous?.enumerable ?? true, writable: true, value: wrapped }
|
|
42
|
+
Object.defineProperty(target, 'fetch', installed)
|
|
43
|
+
const installation = { previous, disposed: false }
|
|
44
|
+
installations.set(wrapped, installation)
|
|
45
|
+
return () => {
|
|
46
|
+
if (installation.disposed) return
|
|
47
|
+
installation.disposed = true
|
|
48
|
+
const current = Object.getOwnPropertyDescriptor(target, 'fetch')
|
|
49
|
+
if (!current || !Object.hasOwn(current, 'value') || current.value !== wrapped ||
|
|
50
|
+
current.configurable !== installed.configurable ||
|
|
51
|
+
current.enumerable !== installed.enumerable || current.writable !== installed.writable) return
|
|
52
|
+
const restore = livePreviousDescriptor(previous)
|
|
53
|
+
if (restore) Object.defineProperty(target, 'fetch', restore)
|
|
54
|
+
else delete target.fetch
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
function boundedAttachmentId(block) {
|
|
2
|
+
const attachment = block && typeof block === 'object' ? block.attachment : undefined
|
|
3
|
+
const raw = attachment && (attachment.attachmentId ?? attachment.id)
|
|
4
|
+
if (typeof raw !== 'string' || raw.trim() === '') return undefined
|
|
5
|
+
return raw.trim().slice(0, 256)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** DSH 0.1.6+ marks durable request-limit omissions on the ImageBlock itself. */
|
|
9
|
+
export function isOffloadedImageBlock(block) {
|
|
10
|
+
return !!block && block.type === 'image' && block.offloaded === true
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function blocksHaveRetainedImage(content) {
|
|
14
|
+
if (!Array.isArray(content)) return false
|
|
15
|
+
for (const block of content) {
|
|
16
|
+
if (!block || typeof block !== 'object') continue
|
|
17
|
+
if (block.type === 'image' && block.offloaded !== true) return true
|
|
18
|
+
if (Array.isArray(block.content) && blocksHaveRetainedImage(block.content)) return true
|
|
19
|
+
}
|
|
20
|
+
return false
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Dependency-free model-facing placeholder for an image DSH already offloaded.
|
|
25
|
+
*
|
|
26
|
+
* Do not import the 0.1.6 `offloadedImageText` helper here: DVR still supports
|
|
27
|
+
* older Hosts where that export does not exist. The durable attachment stays
|
|
28
|
+
* available to Vision Router tools, but custom wire serializers must never
|
|
29
|
+
* read or resend its bytes once the Host projection marks this occurrence.
|
|
30
|
+
*/
|
|
31
|
+
export function offloadedImagePlaceholder(block) {
|
|
32
|
+
const id = boundedAttachmentId(block)
|
|
33
|
+
if (id === undefined) {
|
|
34
|
+
return '[image omitted to fit request image limits; attachment id unavailable]'
|
|
35
|
+
}
|
|
36
|
+
return `[image omitted to fit request image limits; attachment ${id}. ` +
|
|
37
|
+
`To inspect it again, call vision_describe with attachmentIds: [${JSON.stringify(id)}].]`
|
|
38
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { captureFetchDelegate, installFetchWrapper } from './fetch-wrapper-lifecycle.js'
|
|
1
2
|
// H2: retain the legacy Host-owned proxy override as a scoped compatibility
|
|
2
3
|
// transport, not as process-wide routing authority. The wrapper is always
|
|
3
4
|
// transparent unless one DVR-owned visual adapter call is active (or the
|
|
@@ -184,7 +185,7 @@ function proxyHostsOf(config) {
|
|
|
184
185
|
* the narrow legacy direct whole-turn fallback above.
|
|
185
186
|
*/
|
|
186
187
|
export function installLegacyGlobalProxyBoundary(ctx, config = {}, options = {}) {
|
|
187
|
-
const originalFetch = typeof options.originalFetch === 'function' ? options.originalFetch :
|
|
188
|
+
const originalFetch = typeof options.originalFetch === 'function' ? options.originalFetch : captureFetchDelegate()
|
|
188
189
|
const importUndici = typeof options.importUndici === 'function' ? options.importUndici : () => import('undici')
|
|
189
190
|
if (typeof originalFetch !== 'function') return () => {}
|
|
190
191
|
|
|
@@ -227,14 +228,14 @@ export function installLegacyGlobalProxyBoundary(ctx, config = {}, options = {})
|
|
|
227
228
|
})
|
|
228
229
|
}
|
|
229
230
|
|
|
230
|
-
|
|
231
|
+
const restoreFetch = installFetchWrapper(scopedFetch)
|
|
231
232
|
let disposed = false
|
|
232
233
|
const dispose = () => {
|
|
233
234
|
if (disposed) return
|
|
234
235
|
disposed = true
|
|
235
236
|
active = false
|
|
236
237
|
void dispatcherPool.dispose()
|
|
237
|
-
|
|
238
|
+
restoreFetch()
|
|
238
239
|
}
|
|
239
240
|
if (typeof ctx?.effect === 'function') {
|
|
240
241
|
try {
|
|
@@ -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) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { captureFetchDelegate, installFetchWrapper } from './fetch-wrapper-lifecycle.js'
|
|
1
2
|
import { currentVisionSessionAffinityId } from './session-affinity-runtime.js'
|
|
2
3
|
import { isOfficialOpenCodeGoUrl, openCodeSessionAffinityHeaderForUrl } from './session-affinity.js'
|
|
3
4
|
|
|
@@ -283,7 +284,7 @@ export function applyPiAiBridgeWireFacts(init, body, facts) {
|
|
|
283
284
|
* wrapper even if another later patch sits above it in the fetch chain.
|
|
284
285
|
*/
|
|
285
286
|
export function installPiAiBridgeWireCompat(ctx, logger) {
|
|
286
|
-
const original =
|
|
287
|
+
const original = captureFetchDelegate()
|
|
287
288
|
if (typeof original !== 'function') return () => {}
|
|
288
289
|
let active = true
|
|
289
290
|
const wrapped = async (input, init) => {
|
|
@@ -334,10 +335,10 @@ export function installPiAiBridgeWireCompat(ctx, logger) {
|
|
|
334
335
|
}
|
|
335
336
|
return Reflect.apply(original, globalThis, [input, patchedInit])
|
|
336
337
|
}
|
|
337
|
-
|
|
338
|
+
const restoreFetch = installFetchWrapper(wrapped)
|
|
338
339
|
const cleanup = () => {
|
|
339
340
|
active = false
|
|
340
|
-
|
|
341
|
+
restoreFetch()
|
|
341
342
|
}
|
|
342
343
|
try {
|
|
343
344
|
ctx?.effect?.(() => cleanup, 'vision-router: pi-ai bridge wire compatibility')
|
package/lib/pixel-diff-stream.js
CHANGED
|
@@ -37,12 +37,18 @@ class AsyncByteReader {
|
|
|
37
37
|
function cellBounds(index, width, height, cols, rows) {
|
|
38
38
|
const cx = index % cols
|
|
39
39
|
const cy = Math.floor(index / cols)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
// Uniform boundaries mirror the counting map floor(x * cells / extent)
|
|
41
|
+
// exactly: with cells <= extent (the grid clamp above guarantees it),
|
|
42
|
+
// ceil(c * extent / cells) rises by at least 1 per cell, so every cell is
|
|
43
|
+
// non-empty and every pixel lands inside the cell the counters credited
|
|
44
|
+
// it to. The previous ceil-stride bounds drifted from that map whenever
|
|
45
|
+
// extent was not a multiple of cells, shifting worst-region boxes and, on
|
|
46
|
+
// tiny extents, dropping counted pixels from cells whose bounds collapsed
|
|
47
|
+
// to zero width.
|
|
48
|
+
const x1 = Math.ceil((cx * width) / cols)
|
|
49
|
+
const y1 = Math.ceil((cy * height) / rows)
|
|
50
|
+
const x2 = Math.ceil(((cx + 1) * width) / cols)
|
|
51
|
+
const y2 = Math.ceil(((cy + 1) * height) / rows)
|
|
46
52
|
return { x1, y1, x2, y2, total: Math.max(0, x2 - x1) * Math.max(0, y2 - y1) }
|
|
47
53
|
}
|
|
48
54
|
|
|
@@ -176,6 +176,24 @@ export async function authorizeRemoteSettingsAfterRiskConfirmation(settings, pay
|
|
|
176
176
|
}
|
|
177
177
|
}
|
|
178
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
|
+
|
|
179
197
|
export function createVisionRouterRemoteSettingsHandler(settings, logger) {
|
|
180
198
|
return async (endpoint, payload) => {
|
|
181
199
|
if (endpoint === 'describe') {
|
|
@@ -225,7 +243,7 @@ export function installVisionRouterRemoteSettingsBridge(ctx, logger) {
|
|
|
225
243
|
ctx.inject(['settings', 'connection', 'webServer'], (remoteCtx) => {
|
|
226
244
|
const handler = createVisionRouterRemoteSettingsHandler(remoteCtx.settings, logger ?? remoteCtx.logger)
|
|
227
245
|
remoteCtx.effect(
|
|
228
|
-
() => remoteCtx.connection
|
|
246
|
+
() => registerVisionRouterRemoteSettingsChannel(remoteCtx.connection, handler),
|
|
229
247
|
'vision-router: opt-in remote settings channel',
|
|
230
248
|
)
|
|
231
249
|
})
|