dsh-vision-router 2.0.0 → 2.1.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.
Files changed (92) hide show
  1. package/README.md +33 -16
  2. package/README.zh.md +34 -17
  3. package/cordis.patch.yml +19 -9
  4. package/docs/architecture/2x-contract-ledger.md +165 -0
  5. package/docs/architecture/compat-inventory.md +121 -0
  6. package/docs/architecture/dsh-compatibility-matrix.md +57 -0
  7. package/docs/architecture/dsh-support-window.md +50 -0
  8. package/docs/architecture/final-closure-audit.md +148 -0
  9. package/docs/architecture/p2-jobs-feasibility.md +94 -0
  10. package/docs/architecture/p3-compat-retirement.md +46 -0
  11. package/docs/architecture/p3-host-native-seams.md +43 -0
  12. package/docs/architecture/p3-native-recovery-evaluation.md +65 -0
  13. package/docs/architecture/runtime-boundaries.md +124 -0
  14. package/docs/releases/v2.0.1.md +17 -0
  15. package/docs/releases/v2.1.0-support-window.md +29 -0
  16. package/docs/releases/v2.1.0.md +83 -0
  17. package/entry.js +13 -307
  18. package/index.js +85 -352
  19. package/lib/abort-signal-compat.js +191 -0
  20. package/lib/adapter-update-coalescer.js +113 -29
  21. package/lib/adversarial-hardening.js +24 -9
  22. package/lib/artifact-boundary.js +16 -175
  23. package/lib/artifact-io.js +220 -0
  24. package/lib/artifact-retention.js +305 -53
  25. package/lib/catalog-corrections.js +6 -1
  26. package/lib/client-host-compat-prelude.js +249 -0
  27. package/lib/client-presentation-boundary.js +61 -3
  28. package/lib/client.js +71 -72
  29. package/lib/core-vision-surface.js +124 -0
  30. package/lib/depth-guidance.js +114 -90
  31. package/lib/doctor-cli-p0.js +168 -0
  32. package/lib/doctor-cli.js +7 -0
  33. package/lib/doctor-vision-limits.js +72 -0
  34. package/lib/dsh-contract-compat.js +207 -39
  35. package/lib/dsh-host-capabilities.js +107 -0
  36. package/lib/dsh-support-window.js +48 -0
  37. package/lib/http-compat.js +22 -6
  38. package/lib/image-resource-governor.js +35 -10
  39. package/lib/legacy-core-vision-policy-bridge.js +25 -226
  40. package/lib/legacy-global-proxy-boundary.js +127 -0
  41. package/lib/live-model-client-prelude.js +0 -12
  42. package/lib/mixed-router.js +81 -81
  43. package/lib/public-entry.js +42 -4
  44. package/lib/runtime-composition.js +348 -0
  45. package/lib/runtime-i18n-boundary.js +683 -0
  46. package/lib/runtime-i18n-core-scope.js +56 -0
  47. package/lib/runtime-i18n-core.js +34 -0
  48. package/lib/runtime-i18n.js +121 -0
  49. package/lib/session-surface-policy.js +97 -0
  50. package/lib/session-vision-index.js +282 -0
  51. package/lib/session-vision-runtime.js +48 -0
  52. package/lib/settings-factory-lifecycle.js +144 -0
  53. package/lib/settings-ia-client-prelude.js +74 -2
  54. package/lib/settings-limit-client-prelude.js +110 -0
  55. package/lib/settings-native-card-layout.js +297 -0
  56. package/lib/settings-number-contract.js +29 -0
  57. package/lib/structured-flow-hardening.js +207 -76
  58. package/lib/tesseract-exec-compat.js +58 -32
  59. package/lib/update-check.js +17 -5
  60. package/lib/v2-settings-ia-integration.js +9 -4
  61. package/lib/vision-artifact-store.js +77 -0
  62. package/lib/vision-backend-runtime-policy.js +6 -2
  63. package/lib/vision-background-benchmark.js +130 -41
  64. package/lib/vision-background-failure-policy.js +70 -0
  65. package/lib/vision-background-stop-store.js +54 -9
  66. package/lib/vision-capability-benchmark-client.js +57 -66
  67. package/lib/vision-capability-benchmark-presentation.js +97 -0
  68. package/lib/vision-capability-benchmark-service.js +3 -3
  69. package/lib/vision-execution-order-apply.js +61 -0
  70. package/lib/vision-execution-order-plan.js +64 -0
  71. package/lib/vision-execution-order.js +38 -0
  72. package/lib/vision-limit-diagnostics.js +202 -0
  73. package/lib/vision-product-presentation.js +284 -0
  74. package/lib/vision-provider-transport.js +197 -0
  75. package/lib/vision-resilience.js +87 -45
  76. package/lib/vision-routing-evidence.js +366 -0
  77. package/lib/vision-routing-runtime.js +278 -0
  78. package/lib/vision-tool-runtime-boundary.js +27 -21
  79. package/lib/vision-turn-budget-client-prelude.js +9 -9
  80. package/lib/web/benchmark-panel.js +6 -0
  81. package/lib/web/diagnostics-panel.js +44 -0
  82. package/lib/web/index.js +60 -0
  83. package/lib/web/model-picker.js +22 -0
  84. package/lib/web/onboarding.js +6 -0
  85. package/lib/web/product-state.js +59 -0
  86. package/lib/web/remote-settings-client.js +8 -0
  87. package/lib/web/routing-section.js +6 -0
  88. package/lib/web/settings-controller.js +6 -0
  89. package/lib/web-capability-boundary.js +65 -9
  90. package/lib/windows-screenshot-dpi-compat.js +148 -0
  91. package/package.json +14 -6
  92. package/lib/vision-capability-shadow.js +0 -576
@@ -0,0 +1,72 @@
1
+ import { existsSync, readFileSync, statSync, openSync, readSync, closeSync } from 'node:fs'
2
+
3
+ const LIMIT_LINE = /vision-router: effective vision limits\s+taskTimeoutMs=(\d+)\s+taskSource=([^\s]+)\s+turnBudgetMs=(\d+)\s+turnSource=([^\s]+)/i
4
+ const EXHAUSTED_LINE = /vision-router: vision turn deadline exhausted\s+turn=([^\s]+)\s+budgetMs=(\d+)\s+elapsedMs=([^\s]+)/i
5
+
6
+ function readTail(file, maxBytes = 2 * 1024 * 1024) {
7
+ if (!file || !existsSync(file)) return ''
8
+ let fd
9
+ try {
10
+ const stat = statSync(file)
11
+ const size = Math.min(stat.size, maxBytes)
12
+ if (size <= 0) return ''
13
+ const buffer = Buffer.alloc(size)
14
+ fd = openSync(file, 'r')
15
+ readSync(fd, buffer, 0, size, Math.max(0, stat.size - size))
16
+ return buffer.toString('utf8')
17
+ } catch {
18
+ try { return readFileSync(file, 'utf8') } catch { return '' }
19
+ } finally {
20
+ if (fd !== undefined) closeSync(fd)
21
+ }
22
+ }
23
+
24
+ export function parseVisionLimitLog(text) {
25
+ let latest
26
+ let latestExhaustion
27
+ for (const line of String(text || '').split(/\r?\n/)) {
28
+ const limit = LIMIT_LINE.exec(line)
29
+ if (limit) {
30
+ latest = {
31
+ taskTimeoutMs: Number(limit[1]),
32
+ taskSource: limit[2],
33
+ turnBudgetMs: Number(limit[3]),
34
+ turnSource: limit[4],
35
+ }
36
+ }
37
+ const exhausted = EXHAUSTED_LINE.exec(line)
38
+ if (exhausted) {
39
+ latestExhaustion = {
40
+ turn: exhausted[1],
41
+ budgetMs: Number(exhausted[2]),
42
+ elapsedMs: exhausted[3] === 'unknown' ? undefined : Number(exhausted[3]),
43
+ }
44
+ }
45
+ }
46
+ if (!latest && !latestExhaustion) return undefined
47
+ return {
48
+ ...latest,
49
+ latestExhaustion,
50
+ explicitTurnLimit: Number(latest?.turnBudgetMs) > 0,
51
+ }
52
+ }
53
+
54
+ export function inspectDoctorVisionLimits(logFile) {
55
+ return parseVisionLimitLog(readTail(logFile))
56
+ }
57
+
58
+ export function formatDoctorVisionLimits(limits) {
59
+ if (!limits) return []
60
+ const task = Number.isFinite(limits.taskTimeoutMs) ? `${limits.taskTimeoutMs / 1000}s` : 'unknown'
61
+ const turn = Number.isFinite(limits.turnBudgetMs)
62
+ ? limits.turnBudgetMs === 0 ? 'unlimited' : `${limits.turnBudgetMs / 1000}s`
63
+ : 'unknown'
64
+ const lines = [
65
+ `Vision task timeout: ${task}${limits.taskSource ? ` (${limits.taskSource})` : ''}`,
66
+ `Vision turn deadline: ${turn}${limits.turnSource ? ` (${limits.turnSource})` : ''}`,
67
+ ]
68
+ if (limits.explicitTurnLimit) {
69
+ lines.push('WARN: an explicit whole-turn vision deadline is active; v2 defaults to unlimited and long multi-image tasks may stop after this deadline.')
70
+ }
71
+ return lines
72
+ }
@@ -1,9 +1,30 @@
1
+ import { inspectDshHostCapabilities } from './dsh-host-capabilities.js'
2
+
3
+ /**
4
+ * Compatibility inventory
5
+ * Reason: preserve Vision Router's released behavior across the supported DSH Host window.
6
+ * Host gap: attachment ownership, settings-section and adapter-ownership seams differ across supported Hosts.
7
+ * First needed for: rc.6 minimum support and the later batch-attachment/settings migration.
8
+ * Feature detection: concrete attachment/settings/LLM methods only; never a DSH version string.
9
+ * Removal condition: the minimum supported Host natively provides every seam used below and the legacy profile overlay is outside the support window.
10
+ * Tests: rc6-rc7-compat, rc6-real-settings-persistence, attachment-admission-policy, DSH contract CI.
11
+ */
12
+
1
13
  const HOST_OWNED_ROUTES = new Set(['deepseek-official', 'deepseek-official-native'])
2
14
 
3
15
  const LEGACY_VISION_ATTACHMENT_BYTES = 20 * 1024 * 1024
4
16
  const LEGACY_VISION_ATTACHMENT_PIXELS = 100_000_000
5
17
  const DSH_RC8_DEFAULT_IMAGE_DIMENSION = 2000
18
+ const DSH_ALPHA_DEFAULT_IMAGE_DIMENSION = 8192
6
19
  const VISION_ROUTER_IMAGE_DIMENSION = 10_000
20
+ const DSH_ALPHA_DEFAULT_NORMALIZED_PIXELS = 2048 * 2048
21
+ const DSH_ALPHA_DEFAULT_NORMALIZED_DIMENSION = 8192
22
+ const DSH_ALPHA_DEFAULT_NORMALIZED_BYTES = 4 * 1024 * 1024
23
+ const VISION_ROUTER_NORMALIZED_PIXELS = LEGACY_VISION_ATTACHMENT_PIXELS
24
+ const VISION_ROUTER_NORMALIZED_DIMENSION = VISION_ROUTER_IMAGE_DIMENSION
25
+ const VISION_ROUTER_NORMALIZED_BYTES = LEGACY_VISION_ATTACHMENT_BYTES
26
+ const HOST_CAPABILITIES_PATH = '/_dsh/vision-router/host-capabilities'
27
+ const capabilityRouteInstalls = new WeakSet()
7
28
 
8
29
  function attachmentStoreOf(ctx) {
9
30
  if (!ctx || typeof ctx !== 'object') return undefined
@@ -23,6 +44,61 @@ function attachmentStoreOf(ctx) {
23
44
  }
24
45
  }
25
46
 
47
+ function sendCapabilityJson(res, status, body) {
48
+ res.writeHead(status, {
49
+ 'content-type': 'application/json; charset=utf-8',
50
+ 'cache-control': 'no-store',
51
+ })
52
+ res.end(JSON.stringify(body))
53
+ }
54
+
55
+ /**
56
+ * Expose the same side-effect-free Host capability snapshot Doctor consumes.
57
+ * The endpoint is GET-only and contains no settings values, credentials,
58
+ * provider ids or version-derived guesses. Failing to install it is advisory.
59
+ */
60
+ export function installDshHostCapabilityDiagnostics(ctx) {
61
+ if (!ctx || (typeof ctx !== 'object' && typeof ctx !== 'function')) return false
62
+ if (capabilityRouteInstalls.has(ctx)) return true
63
+ capabilityRouteInstalls.add(ctx)
64
+ try {
65
+ if (typeof ctx.inject !== 'function') return false
66
+ ctx.inject(['webServer'], (webCtx) => {
67
+ try {
68
+ webCtx.effect(
69
+ () => webCtx.webServer.register({
70
+ kind: 'exact',
71
+ path: HOST_CAPABILITIES_PATH,
72
+ handler: async (req, res) => {
73
+ if (req.method !== 'GET') {
74
+ res.setHeader('Allow', 'GET')
75
+ sendCapabilityJson(res, 405, { ok: false, error: 'method not allowed' })
76
+ return
77
+ }
78
+ let capabilities
79
+ try {
80
+ capabilities = inspectDshHostCapabilities(webCtx)
81
+ } catch {
82
+ capabilities = undefined
83
+ }
84
+ sendCapabilityJson(res, 200, {
85
+ ok: true,
86
+ capabilities: capabilities ?? {},
87
+ })
88
+ },
89
+ }),
90
+ 'vision-router: host capability diagnostics route',
91
+ )
92
+ } catch {
93
+ // Diagnostics must never veto webServer injection or plugin startup.
94
+ }
95
+ })
96
+ return true
97
+ } catch {
98
+ return false
99
+ }
100
+ }
101
+
26
102
  /**
27
103
  * Detect the released batch-attachment contract directly. DSH rc.6 exposes
28
104
  * validateImage/saveImage/readImage only; rc.7+ adds AttachmentStore.saveImages().
@@ -35,22 +111,58 @@ export function hasBatchAttachmentContract(ctx) {
35
111
  return !!store && typeof store.saveImages === 'function'
36
112
  }
37
113
 
114
+ function exactAlphaNormalizationDefaults(policy) {
115
+ return !!policy && typeof policy === 'object' &&
116
+ policy.maxPixels === DSH_ALPHA_DEFAULT_NORMALIZED_PIXELS &&
117
+ policy.maxDimension === DSH_ALPHA_DEFAULT_NORMALIZED_DIMENSION &&
118
+ policy.maxBytes === DSH_ALPHA_DEFAULT_NORMALIZED_BYTES
119
+ }
120
+
121
+ function knownVisionRouterAdmissionShape(limits) {
122
+ if (!limits || typeof limits !== 'object') return false
123
+ if (
124
+ limits.maxImageBytes !== LEGACY_VISION_ATTACHMENT_BYTES ||
125
+ limits.maxImagePixels !== LEGACY_VISION_ATTACHMENT_PIXELS
126
+ ) return false
127
+ return limits.maxImageDimension === DSH_RC8_DEFAULT_IMAGE_DIMENSION ||
128
+ limits.maxImageDimension === DSH_ALPHA_DEFAULT_IMAGE_DIMENSION
129
+ }
130
+
131
+ function replaceFrozenPolicy(store, property, next) {
132
+ let changed = false
133
+ try {
134
+ changed = Reflect.set(store, property, next)
135
+ } catch {
136
+ changed = false
137
+ }
138
+ return changed && store[property] === next
139
+ }
140
+
38
141
  /**
39
- * Repair one historical Vision Router profile overlay on DSH rc.8.
142
+ * Repair historical Vision Router profile overlays across supported DSH Hosts.
143
+ *
144
+ * DSH patch rows replace the WHOLE config object instead of deep-merging it.
145
+ * Old Vision Router profiles therefore retain a later `attachment-local` row
146
+ * containing only the released 20MiB / 100MP admission pair. On rc.8 that
147
+ * shadowed the later 10000px bundle value and rematerialized the Host's 2000px
148
+ * default. On 0.1.2-alpha.1 the same stale row additionally omits the new
149
+ * normalization fields, rematerializing 8192px admission plus the Host's
150
+ * 4.2MP / 4MiB canonical-image defaults.
40
151
  *
41
- * Before rc.8 introduced maxImageDimension, Vision Router wrote a profile-local
42
- * attachment-local override containing only 20MiB / 100MP. DSH patch rows
43
- * replace the WHOLE config object instead of deep-merging it, so that later
44
- * profile layer shadows the newer bundle row and rc.8 silently rematerializes
45
- * its own 2000px default. Plugin updates cannot delete a user's profile layer.
152
+ * This migration never edits a user's profile file and never creates another
153
+ * attachment owner. LocalAttachmentStore exposes the resolved immutable
154
+ * `imageLimits` and (alpha+) `normalizationPolicy` objects, and reads those
155
+ * properties for every later validate/save. We replace only those policy
156
+ * objects on one exact historical fingerprint:
46
157
  *
47
- * The historical 20MiB + 100MP pair is therefore the migration fingerprint.
48
- * Only when that exact pair is combined with rc.8's exact 2000px default do we
49
- * raise the live store policy to Vision Router's 10000px contract. Explicitly
50
- * customised dimensions or unrelated attachment deployments are never touched.
51
- * LocalAttachmentStore reads `this.imageLimits` for every validation/save, so
52
- * replacing this immutable policy object repairs authoritative admission as
53
- * well as the limits projected to the browser for error copy.
158
+ * - admission bytes === 20MiB and pixels === 100MP; and
159
+ * - dimension is rc.8's 2000px default or alpha's 8192px default; and
160
+ * - normalization, when repaired, is EXACTLY alpha.1's 4.2MP/8192px/4MiB
161
+ * default tuple.
162
+ *
163
+ * Any custom admission dimension or any custom normalization tuple is left
164
+ * untouched. This is capability/shape detection only; no DSH version string is
165
+ * consulted.
54
166
  */
55
167
  export function ensureVisionAttachmentAdmissionPolicy(ctx, logger, options = {}) {
56
168
  const store = attachmentStoreOf(ctx)
@@ -62,41 +174,96 @@ export function ensureVisionAttachmentAdmissionPolicy(ctx, logger, options = {})
62
174
  const targetDimension = Number.isInteger(options.maxImageDimension)
63
175
  ? Math.max(1, options.maxImageDimension)
64
176
  : VISION_ROUTER_IMAGE_DIMENSION
65
- const legacy =
66
- limits.maxImageBytes === LEGACY_VISION_ATTACHMENT_BYTES &&
67
- limits.maxImagePixels === LEGACY_VISION_ATTACHMENT_PIXELS &&
68
- limits.maxImageDimension === DSH_RC8_DEFAULT_IMAGE_DIMENSION
177
+ const targetNormalizedPixels = Number.isInteger(options.normalizedImageMaxPixels)
178
+ ? Math.max(1, options.normalizedImageMaxPixels)
179
+ : VISION_ROUTER_NORMALIZED_PIXELS
180
+ const targetNormalizedDimension = Number.isInteger(options.normalizedImageMaxDimension)
181
+ ? Math.max(1, options.normalizedImageMaxDimension)
182
+ : VISION_ROUTER_NORMALIZED_DIMENSION
183
+ const targetNormalizedBytes = Number.isInteger(options.normalizedImageMaxBytes)
184
+ ? Math.max(1, options.normalizedImageMaxBytes)
185
+ : VISION_ROUTER_NORMALIZED_BYTES
69
186
 
70
- if (!legacy) return { changed: false, reason: 'not-legacy-overlay', limits }
187
+ if (!knownVisionRouterAdmissionShape(limits)) {
188
+ return { changed: false, reason: 'not-legacy-overlay', limits }
189
+ }
71
190
 
72
- const next = Object.freeze({ ...limits, maxImageDimension: targetDimension })
73
- let changed = false
74
- try {
75
- changed = Reflect.set(store, 'imageLimits', next)
76
- } catch {
77
- changed = false
191
+ const repairNormalization =
192
+ limits.maxImageDimension === DSH_ALPHA_DEFAULT_IMAGE_DIMENSION &&
193
+ exactAlphaNormalizationDefaults(store.normalizationPolicy)
194
+ const repairDimension =
195
+ limits.maxImageDimension === DSH_RC8_DEFAULT_IMAGE_DIMENSION ||
196
+ repairNormalization
197
+
198
+ if (!repairDimension && !repairNormalization) {
199
+ return { changed: false, reason: 'not-legacy-overlay', limits }
78
200
  }
79
- if (!changed || store.imageLimits !== next) {
80
- try {
81
- logger?.warn?.(
82
- 'vision-router: detected the legacy attachment-local 20MiB/100MP overlay but could not repair maxImageDimension; DSH may still enforce 2000px',
83
- )
84
- } catch {
85
- // Diagnostics must never break Host startup.
201
+
202
+ if (repairDimension) {
203
+ const nextLimits = Object.freeze({ ...limits, maxImageDimension: targetDimension })
204
+ if (!replaceFrozenPolicy(store, 'imageLimits', nextLimits)) {
205
+ try {
206
+ logger?.warn?.(
207
+ 'vision-router: detected the legacy attachment-local admission overlay but could not repair maxImageDimension; DSH may still enforce the Host default',
208
+ )
209
+ } catch {
210
+ // Diagnostics must never break Host startup.
211
+ }
212
+ return { changed: false, reason: 'limits-readonly', limits }
213
+ }
214
+ }
215
+
216
+ if (repairNormalization) {
217
+ const nextNormalization = Object.freeze({
218
+ maxPixels: targetNormalizedPixels,
219
+ maxDimension: targetNormalizedDimension,
220
+ maxBytes: targetNormalizedBytes,
221
+ })
222
+ if (!replaceFrozenPolicy(store, 'normalizationPolicy', nextNormalization)) {
223
+ // If admission was already repaired above, leave that safe improvement in
224
+ // place but report the unresolved canonical-image blocker explicitly.
225
+ try {
226
+ logger?.warn?.(
227
+ 'vision-router: detected the legacy attachment-local alpha normalization defaults but could not repair normalizationPolicy; canonical images may still be downscaled',
228
+ )
229
+ } catch {
230
+ // Diagnostics must never break Host startup.
231
+ }
232
+ return {
233
+ changed: repairDimension,
234
+ reason: 'normalization-policy-readonly',
235
+ limits: store.imageLimits,
236
+ normalizationPolicy: store.normalizationPolicy,
237
+ }
86
238
  }
87
- return { changed: false, reason: 'limits-readonly', limits }
88
239
  }
89
240
 
90
241
  try {
91
- logger?.info?.(
92
- 'vision-router: repaired legacy attachment-local overlay — maxImageDimension %d -> %d',
93
- DSH_RC8_DEFAULT_IMAGE_DIMENSION,
94
- targetDimension,
95
- )
242
+ if (repairNormalization) {
243
+ logger?.info?.(
244
+ 'vision-router: repaired legacy attachment-local policy — admission %dpx; canonical image %dpx/%dpx/%d bytes',
245
+ store.imageLimits.maxImageDimension,
246
+ store.normalizationPolicy.maxPixels,
247
+ store.normalizationPolicy.maxDimension,
248
+ store.normalizationPolicy.maxBytes,
249
+ )
250
+ } else {
251
+ logger?.info?.(
252
+ 'vision-router: repaired legacy attachment-local overlay — maxImageDimension %d -> %d',
253
+ DSH_RC8_DEFAULT_IMAGE_DIMENSION,
254
+ targetDimension,
255
+ )
256
+ }
96
257
  } catch {
97
258
  // Diagnostics must never break Host startup.
98
259
  }
99
- return { changed: true, reason: 'legacy-overlay-repaired', limits: next }
260
+
261
+ return {
262
+ changed: true,
263
+ reason: repairNormalization ? 'legacy-alpha-policy-repaired' : 'legacy-overlay-repaired',
264
+ limits: store.imageLimits,
265
+ ...(repairNormalization ? { normalizationPolicy: store.normalizationPolicy } : {}),
266
+ }
100
267
  }
101
268
 
102
269
  /**
@@ -269,6 +436,7 @@ export function installHostSettingsCompatibility(ctx, entryConfig, options = {})
269
436
  * host context passes through untouched.
270
437
  */
271
438
  export function attachmentContextForContract(ctx, logger, options = {}) {
439
+ installDshHostCapabilityDiagnostics(ctx)
272
440
  if (hasBatchAttachmentContract(ctx)) return ctx
273
441
  const install = options.installAndroidAttachmentCompat
274
442
  if (typeof install !== 'function') return ctx
@@ -280,4 +448,4 @@ export function attachmentContextForContract(ctx, logger, options = {}) {
280
448
  // releases do not accrete `if (rc8) / if (rc9)` branches.
281
449
  export const isRc7ContractRuntime = hasBatchAttachmentContract
282
450
  export const protectRc7ProviderOwnership = protectHostProviderOwnership
283
- export const installRc7SettingsCompatibility = installHostSettingsCompatibility
451
+ export const installRc7SettingsCompatibility = installHostSettingsCompatibility
@@ -0,0 +1,107 @@
1
+ const UNKNOWN = 'unknown'
2
+
3
+ function serviceOf(ctx, name) {
4
+ if (!ctx || (typeof ctx !== 'object' && typeof ctx !== 'function')) return undefined
5
+ try {
6
+ if (typeof ctx.get === 'function') {
7
+ const service = ctx.get(name)
8
+ if (service !== undefined && service !== null) return service
9
+ }
10
+ } catch {
11
+ // Capability diagnostics are advisory and must not break plugin startup.
12
+ }
13
+ try {
14
+ const service = ctx[name]
15
+ return service === undefined || service === null ? undefined : service
16
+ } catch {
17
+ return undefined
18
+ }
19
+ }
20
+
21
+ function hasFunction(value, name) {
22
+ try {
23
+ return !!value && typeof value[name] === 'function'
24
+ } catch {
25
+ return false
26
+ }
27
+ }
28
+
29
+ function known(value) {
30
+ return value === true || value === false ? value : UNKNOWN
31
+ }
32
+
33
+ /**
34
+ * Return a side-effect-free capability snapshot of the mounted DSH Host.
35
+ *
36
+ * The probe deliberately never registers adapters, tools, jobs, settings or
37
+ * client surfaces just to learn whether a feature exists. If a capability
38
+ * cannot be proven from a stable readable seam, it is reported as `unknown`
39
+ * rather than inferred from a DSH version string.
40
+ */
41
+ export function inspectDshHostCapabilities(ctx) {
42
+ const attachments = serviceOf(ctx, 'attachments')
43
+ const llm = serviceOf(ctx, 'llm')
44
+ const jobs = serviceOf(ctx, 'jobs')
45
+ const settings = serviceOf(ctx, 'settings')
46
+ const tools = serviceOf(ctx, 'tools')
47
+
48
+ let maxImageDimension = false
49
+ try {
50
+ const value = attachments?.imageLimits?.maxImageDimension
51
+ maxImageDimension = Number.isFinite(Number(value)) && Number(value) > 0
52
+ } catch {
53
+ maxImageDimension = false
54
+ }
55
+
56
+ const adapterRegistration = hasFunction(llm, 'registerAdapter')
57
+ const prepareCall = hasFunction(llm, 'prepareCall')
58
+ const toolRegistration = hasFunction(tools, 'register') || hasFunction(tools, 'registerTool')
59
+ const toolExecution = hasFunction(tools, 'call') || hasFunction(tools, 'execute') || hasFunction(tools, 'invoke')
60
+
61
+ return Object.freeze({
62
+ batchAttachments: hasFunction(attachments, 'saveImages'),
63
+ maxImageDimension,
64
+ adapterRegistration,
65
+ // replace() lives on the handle returned by registerAdapter(). A read-only
66
+ // Doctor cannot prove the handle shape without mutating Host topology, so
67
+ // an observable registration seam means "unknown", not "no".
68
+ registrationReplace: adapterRegistration ? UNKNOWN : false,
69
+ jobs: jobs === undefined ? false : true,
70
+ // No stable read-only client-surface capability flag exists across the
71
+ // supported Host window yet.
72
+ surfaceReplacement: UNKNOWN,
73
+ // DSH SettingsProvider.register() is the public live-namespace seam; get()
74
+ // and watch() belong to the returned SettingsScope rather than the service.
75
+ // Detect the stable service method and do not manufacture a registration.
76
+ settingsLiveNamespace: hasFunction(settings, 'register'),
77
+ // Browser exposure is a presentation capability and is not safely implied
78
+ // by the Host settings service alone.
79
+ settingsWebExposure: UNKNOWN,
80
+ prepareCall,
81
+ toolRegistration,
82
+ toolExecution,
83
+ })
84
+ }
85
+
86
+ export function normalizeDshHostCapabilities(value) {
87
+ const source = value && typeof value === 'object' ? value : {}
88
+ return Object.freeze({
89
+ batchAttachments: known(source.batchAttachments),
90
+ maxImageDimension: known(source.maxImageDimension),
91
+ adapterRegistration: known(source.adapterRegistration),
92
+ registrationReplace: known(source.registrationReplace),
93
+ jobs: known(source.jobs),
94
+ surfaceReplacement: known(source.surfaceReplacement),
95
+ settingsLiveNamespace: known(source.settingsLiveNamespace),
96
+ settingsWebExposure: known(source.settingsWebExposure),
97
+ prepareCall: known(source.prepareCall),
98
+ toolRegistration: known(source.toolRegistration),
99
+ toolExecution: known(source.toolExecution),
100
+ })
101
+ }
102
+
103
+ export function formatDshHostCapability(value) {
104
+ if (value === true) return 'yes'
105
+ if (value === false) return 'no'
106
+ return UNKNOWN
107
+ }
@@ -0,0 +1,48 @@
1
+ export const DSH_SUPPORT_WINDOW = Object.freeze({
2
+ dvrTrain: '2.1.x',
3
+ minimum: '0.1.0-rc.8',
4
+ previous: '0.1.1-rc.1',
5
+ current: '0.1.1-rc.2',
6
+ canary: '0.1.2-alpha.4',
7
+ })
8
+
9
+ export function supportWindowUpgradeAdvice(capabilities = {}) {
10
+ const batchAttachments = capabilities?.batchAttachments
11
+ const maxImageDimension = capabilities?.maxImageDimension
12
+
13
+ if (batchAttachments === false || maxImageDimension === false) {
14
+ return Object.freeze({
15
+ level: 'required',
16
+ code: 'HOST_BELOW_CURRENT_FLOOR_CAPABILITIES',
17
+ message: `Host lacks attachment capabilities expected by DVR ${DSH_SUPPORT_WINDOW.dvrTrain}; the minimum supported Host is DSH ${DSH_SUPPORT_WINDOW.minimum}`,
18
+ })
19
+ }
20
+
21
+ if (batchAttachments === 'unknown' || maxImageDimension === 'unknown') {
22
+ return Object.freeze({
23
+ level: 'unknown',
24
+ code: 'HOST_CURRENT_FLOOR_UNKNOWN',
25
+ message: `DVR ${DSH_SUPPORT_WINDOW.dvrTrain} requires DSH ${DSH_SUPPORT_WINDOW.minimum} or newer, but this read-only probe cannot prove the relevant Host capabilities`,
26
+ })
27
+ }
28
+
29
+ return Object.freeze({
30
+ level: 'ok',
31
+ code: 'HOST_CURRENT_FLOOR_CAPABLE',
32
+ message: `Host exposes the attachment capabilities expected by the DVR ${DSH_SUPPORT_WINDOW.dvrTrain} support floor`,
33
+ })
34
+ }
35
+
36
+ export function formatDshSupportWindowLines(capabilities = {}) {
37
+ const advice = supportWindowUpgradeAdvice(capabilities)
38
+ return Object.freeze([
39
+ 'DSH Host support window:',
40
+ ` DVR train: ${DSH_SUPPORT_WINDOW.dvrTrain}`,
41
+ ` minimum: ${DSH_SUPPORT_WINDOW.minimum}`,
42
+ ` previous: ${DSH_SUPPORT_WINDOW.previous}`,
43
+ ` current: ${DSH_SUPPORT_WINDOW.current}`,
44
+ ` canary only: ${DSH_SUPPORT_WINDOW.canary}`,
45
+ ` support floor: DVR ${DSH_SUPPORT_WINDOW.dvrTrain} -> DSH ${DSH_SUPPORT_WINDOW.minimum}`,
46
+ ` upgrade advice: ${advice.level} (${advice.code}) — ${advice.message}`,
47
+ ])
48
+ }
@@ -1,3 +1,5 @@
1
+ import { currentVisionProviderTransport } from './vision-provider-transport.js'
2
+
1
3
  const GLM_4V_FLASH = /^glm-4v-flash(?:$|[-_.])/i
2
4
  export const DEFAULT_HTTP_ERROR_DETAIL_MAX_BYTES = 64 * 1024
3
5
 
@@ -262,6 +264,11 @@ function nextCompatibilityBody(body, detail) {
262
264
  /**
263
265
  * Fetch wrapper for vision-router's own OpenAI-compatible requests only.
264
266
  *
267
+ * When the P2 provider transport is installed, every active Router-owned call
268
+ * goes through that facade. The `fetchImpl` parameter remains as a compatibility
269
+ * fallback for direct/unit callers, so the behavior outside the public plugin
270
+ * composition is unchanged.
271
+ *
265
272
  * Layer 1: generic request shape (works for unknown/new models).
266
273
  * Layer 2: known model-family presets.
267
274
  * Layer 3: bounded, evidence-driven retries when the server reports an output
@@ -270,18 +277,27 @@ function nextCompatibilityBody(body, detail) {
270
277
  * sequentially without creating an unbounded retry loop.
271
278
  */
272
279
  export async function fetchWithOpenAICompatibility(fetchImpl, input, init, context = {}) {
273
- if (typeof fetchImpl !== 'function' || context.active !== true) {
274
- return fetchImpl(input, init)
280
+ if (typeof fetchImpl !== 'function') throw new TypeError('fetch implementation is required')
281
+ const transport = context.active === true ? currentVisionProviderTransport() : undefined
282
+ const requestFetch = transport
283
+ ? (nextInput, nextInit) => transport.fetch(nextInput, nextInit, {
284
+ providerName: context.providerName,
285
+ allowProxy: context.allowProxy !== false,
286
+ })
287
+ : fetchImpl
288
+
289
+ if (context.active !== true) {
290
+ return requestFetch(input, init)
275
291
  }
276
292
  if (!isCompatibleChatCompletionRequest(input, init)) {
277
- return fetchImpl(input, init)
293
+ return requestFetch(input, init)
278
294
  }
279
295
 
280
296
  let parsed
281
297
  try {
282
298
  parsed = JSON.parse(init.body)
283
299
  } catch {
284
- return fetchImpl(input, init)
300
+ return requestFetch(input, init)
285
301
  }
286
302
 
287
303
  const url = typeof input === 'string' || input instanceof URL ? input : input?.url
@@ -291,7 +307,7 @@ export async function fetchWithOpenAICompatibility(fetchImpl, input, init, conte
291
307
  prompt: context.prompt,
292
308
  })
293
309
  let requestBody = prepared.body
294
- let response = await fetchImpl(input, { ...init, body: JSON.stringify(requestBody) })
310
+ let response = await requestFetch(input, { ...init, body: JSON.stringify(requestBody) })
295
311
 
296
312
  for (let correction = 0; correction < 2; correction++) {
297
313
  if (response.ok || (response.status !== 400 && response.status !== 422)) return response
@@ -308,7 +324,7 @@ export async function fetchWithOpenAICompatibility(fetchImpl, input, init, conte
308
324
  }
309
325
 
310
326
  requestBody = retryBody
311
- response = await fetchImpl(input, { ...init, body: JSON.stringify(requestBody) })
327
+ response = await requestFetch(input, { ...init, body: JSON.stringify(requestBody) })
312
328
  }
313
329
 
314
330
  return response
@@ -1,6 +1,7 @@
1
1
  import { currentVisionTurnBudgetSignal } from './turn-budget-context.js'
2
2
 
3
3
  const MIB = 1024 * 1024
4
+ export const DEFAULT_IMAGE_RESOURCE_MAX_BYPASSES = 2
4
5
 
5
6
  function abortError() {
6
7
  const error = new Error('image resource wait aborted')
@@ -108,9 +109,14 @@ export function boundedOcrTiles(
108
109
  }
109
110
 
110
111
  export class ImageResourceGovernor {
111
- constructor({ maxBytes = 256 * MIB, maxConcurrent = 2 } = {}) {
112
+ constructor({
113
+ maxBytes = 256 * MIB,
114
+ maxConcurrent = 2,
115
+ maxBypasses = DEFAULT_IMAGE_RESOURCE_MAX_BYPASSES,
116
+ } = {}) {
112
117
  this.maxBytes = Math.max(1, Math.floor(Number(maxBytes) || 256 * MIB))
113
118
  this.maxConcurrent = Math.max(1, Math.floor(Number(maxConcurrent) || 2))
119
+ this.maxBypasses = Math.max(0, Math.floor(Number(maxBypasses) || 0))
114
120
  this.activeBytes = 0
115
121
  this.activeCount = 0
116
122
  this.activeExclusive = false
@@ -156,17 +162,35 @@ export class ImageResourceGovernor {
156
162
  })
157
163
  }
158
164
 
165
+ /**
166
+ * Find work that fits the current byte/concurrency window without letting a
167
+ * large request permanently pin every smaller request behind it. Each blocked
168
+ * non-exclusive request may be bypassed only a small, fixed number of times;
169
+ * after that it becomes a fairness barrier until capacity is released.
170
+ * Exclusive work is always a barrier immediately.
171
+ */
172
+ _nextRunnable() {
173
+ const bypassed = []
174
+ for (let index = 0; index < this.queue.length; index++) {
175
+ const item = this.queue[index]
176
+ if (!item || item.settled) continue
177
+ if (this._canRun(item.request)) return { index, item, bypassed }
178
+ if (item.request.exclusive || item.bypassCount >= this.maxBypasses) return undefined
179
+ bypassed.push(item)
180
+ }
181
+ return undefined
182
+ }
183
+
159
184
  _drain() {
160
185
  while (this.queue.length > 0) {
161
- const item = this.queue[0]
162
- if (item.settled) {
163
- this.queue.shift()
164
- continue
165
- }
166
- if (!this._canRun(item.request)) break
167
- this.queue.shift()
168
- this._grant(item)
169
- if (item.request.exclusive) break
186
+ while (this.queue[0]?.settled) this.queue.shift()
187
+ if (this.queue.length === 0) return
188
+ const selected = this._nextRunnable()
189
+ if (!selected) return
190
+ this.queue.splice(selected.index, 1)
191
+ for (const item of selected.bypassed) item.bypassCount += 1
192
+ this._grant(selected.item)
193
+ if (selected.item.request.exclusive) return
170
194
  }
171
195
  }
172
196
 
@@ -186,6 +210,7 @@ export class ImageResourceGovernor {
186
210
  reject,
187
211
  settled: false,
188
212
  abortHandler: undefined,
213
+ bypassCount: 0,
189
214
  }
190
215
  item.abortHandler = () => {
191
216
  if (item.settled) return