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.
Files changed (46) hide show
  1. package/README.md +8 -14
  2. package/README.zh.md +8 -14
  3. package/docs/architecture/compat-inventory.md +1 -1
  4. package/docs/architecture/dsh-compatibility-matrix.md +4 -1
  5. package/docs/architecture/dsh-support-window.md +1 -2
  6. package/docs/releases/v2.2.0.md +25 -0
  7. package/docs/releases/v2.2.1.md +22 -0
  8. package/docs/remote-settings.md +2 -0
  9. package/entry.js +2 -0
  10. package/index.js +355 -64
  11. package/lib/artifact-boundary.js +11 -0
  12. package/lib/artifact-io.js +60 -7
  13. package/lib/catalog-corrections.js +5 -0
  14. package/lib/client.js +22 -17
  15. package/lib/core-primitives.js +126 -5
  16. package/lib/degraded-local-evidence.js +39 -0
  17. package/lib/dsh-contract-compat.js +225 -0
  18. package/lib/dsh-support-window.js +1 -1
  19. package/lib/fetch-wrapper-lifecycle.js +56 -0
  20. package/lib/image-offload-compat.js +38 -0
  21. package/lib/legacy-global-proxy-boundary.js +4 -3
  22. package/lib/local-vision-stabilizer.js +15 -6
  23. package/lib/official-deepseek-catalog.js +120 -0
  24. package/lib/ollama-cold-start.js +3 -15
  25. package/lib/pi-ai-bridge-wire-compat.js +4 -3
  26. package/lib/pixel-diff-stream.js +12 -6
  27. package/lib/remote-settings-bridge.js +19 -1
  28. package/lib/replay-delegation.js +41 -21
  29. package/lib/runtime-composition.js +15 -2
  30. package/lib/runtime-i18n-boundary.js +4 -13
  31. package/lib/runtime-i18n.js +2 -2
  32. package/lib/session-surface-compat.js +40 -0
  33. package/lib/session-turn-resolver.js +57 -0
  34. package/lib/session-vision-index.js +438 -97
  35. package/lib/session-vision-mode-boundary.js +83 -6
  36. package/lib/session-vision-runtime.js +8 -2
  37. package/lib/session-vision-state.js +0 -13
  38. package/lib/tesseract-exec-compat.js +16 -2
  39. package/lib/twin-image-capability-fallback.js +2 -11
  40. package/lib/vision-artifact-store.js +16 -3
  41. package/lib/vision-attachment-handle-runtime.js +9 -21
  42. package/lib/vision-backend-runtime-policy.js +7 -6
  43. package/lib/vision-breaker-shadow-health.js +26 -6
  44. package/lib/vision-evidence-guidance.js +39 -0
  45. package/lib/vision-resilience.js +73 -1
  46. 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.5-alpha.2',
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 : globalThis.fetch
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
- globalThis.fetch = scopedFetch
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
- if (globalThis.fetch === scopedFetch) globalThis.fetch = originalFetch
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
- const attachment = nested.attachment || {}
204
- const id = attachment.attachmentId || attachment.id || 'unknown'
205
- parts.push(
206
- `[attached image: ${id}] this tool result contained an image; ` +
207
- 'inspect it with vision_describe (or re-read it with read_image)',
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
+ }
@@ -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, core) {
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 = globalThis.fetch
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
- globalThis.fetch = wrapped
338
+ const restoreFetch = installFetchWrapper(wrapped)
338
339
  const cleanup = () => {
339
340
  active = false
340
- if (globalThis.fetch === wrapped) globalThis.fetch = original
341
+ restoreFetch()
341
342
  }
342
343
  try {
343
344
  ctx?.effect?.(() => cleanup, 'vision-router: pi-ai bridge wire compatibility')
@@ -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
- const cw = Math.ceil(width / cols)
41
- const ch = Math.ceil(height / rows)
42
- const x1 = cx * cw
43
- const y1 = cy * ch
44
- const x2 = Math.min((cx + 1) * cw, width)
45
- const y2 = Math.min((cy + 1) * ch, height)
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.rpc.handle(REMOTE_SETTINGS_CHANNEL, handler, { authority: 'trusted-host' }),
246
+ () => registerVisionRouterRemoteSettingsChannel(remoteCtx.connection, handler),
229
247
  'vision-router: opt-in remote settings channel',
230
248
  )
231
249
  })