dsh-vision-router 2.2.8 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/README.md +16 -7
  2. package/README.zh.md +16 -7
  3. package/docs/architecture/2.3.0-compat-retirement.md +147 -0
  4. package/docs/architecture/2.3.0-execution-plan.md +410 -0
  5. package/docs/architecture/2.3.0-final-audit.md +279 -0
  6. package/docs/architecture/2.3.0-reopened-plan.md +720 -0
  7. package/docs/architecture/3.0-architecture-plan.md +252 -0
  8. package/docs/architecture/3.0-r0-contract-ledger.md +137 -0
  9. package/docs/architecture/3.0-r1-source-artifact-boundary.md +98 -0
  10. package/docs/architecture/3.0-r10-final-correctness-closure.md +207 -0
  11. package/docs/architecture/3.0-r11-adversarial-review.md +441 -0
  12. package/docs/architecture/3.0-r5-browser-go-no-go.md +165 -0
  13. package/docs/architecture/3.0-r5-browser-typescript-gate.md +105 -0
  14. package/docs/architecture/3.0-r6-compat-retirement.md +203 -0
  15. package/docs/architecture/3.0-r7-public-api.md +151 -0
  16. package/docs/architecture/3.0-r8-ci-release.md +248 -0
  17. package/docs/architecture/3.0-r9-final-audit.md +370 -0
  18. package/docs/architecture/compat-inventory.md +26 -34
  19. package/docs/architecture/dsh-compatibility-matrix.md +65 -54
  20. package/docs/architecture/dsh-support-window.md +30 -22
  21. package/docs/architecture/final-closure-audit.md +2 -0
  22. package/docs/releases/v2.3.0.md +53 -0
  23. package/docs/releases/v3.0.0.md +85 -0
  24. package/entry.js +9 -57
  25. package/index.js +268 -761
  26. package/lib/agent-request-route-authority.js +1 -20
  27. package/lib/catalog-corrections.js +1 -2
  28. package/lib/client-maintenance-actions.js +145 -0
  29. package/lib/client-presentation-boundary-main.js +75 -17
  30. package/lib/client-presentation-boundary.js +53 -28
  31. package/lib/client.js +246 -144
  32. package/lib/core-apply-lifecycle.js +41 -0
  33. package/lib/core-cache.js +87 -0
  34. package/lib/core-primitives.js +91 -278
  35. package/lib/core-vision-surface-domain.js +63 -0
  36. package/lib/core-vision-surface.js +11 -77
  37. package/lib/delegated-call-config.js +15 -24
  38. package/lib/desktop-screenshot-tool.js +262 -0
  39. package/lib/doctor-cli.js +0 -0
  40. package/lib/doctor-runtime.js +4 -1
  41. package/lib/dsh-contract-compat.js +37 -256
  42. package/lib/dsh-host-capabilities.js +3 -9
  43. package/lib/dsh-settings-017-compat.js +17 -10
  44. package/lib/dsh-support-window.js +3 -3
  45. package/lib/file-logger.js +8 -3
  46. package/lib/grounding-coordinate-runtime.js +1 -2
  47. package/lib/http-body-limit.js +97 -79
  48. package/lib/http-compat.js +1 -2
  49. package/lib/image-offload-compat.js +30 -21
  50. package/lib/image-resource-governor.js +210 -228
  51. package/lib/legacy-global-proxy-boundary.js +46 -3
  52. package/lib/live-model-client-prelude.js +12 -4
  53. package/lib/live-model-discovery.js +134 -43
  54. package/lib/local-remote-settings-permission.js +33 -7
  55. package/lib/local-vision-stabilizer.js +31 -17
  56. package/lib/maintenance-routes.js +134 -0
  57. package/lib/native-image-coexistence.js +15 -3
  58. package/lib/ollama-cold-start.js +87 -23
  59. package/lib/pi-ai-bridge-wire-compat.js +3 -2
  60. package/lib/proxy-dispatcher-pool.js +151 -92
  61. package/lib/proxy-routing.js +75 -51
  62. package/lib/proxy-url-compat.js +3 -10
  63. package/lib/public-config.js +36 -0
  64. package/lib/public-entry.d.ts +148 -0
  65. package/lib/public-entry.js +19 -11
  66. package/lib/remote-settings-bridge.js +11 -0
  67. package/lib/remote-settings-risk-client-boundary.js +155 -0
  68. package/lib/remote-settings-risk-confirmation.js +17 -105
  69. package/lib/replay-delegation.js +12 -7
  70. package/lib/replay-envelope-v2-compat.js +12 -7
  71. package/lib/runtime-composition.js +127 -34
  72. package/lib/runtime-i18n-boundary.js +10 -11
  73. package/lib/screenshot-source-boundary.js +2 -2
  74. package/lib/session-affinity-runtime.js +113 -97
  75. package/lib/session-affinity.js +89 -76
  76. package/lib/session-event-history-compat.js +30 -0
  77. package/lib/session-message-source-compat.js +7 -3
  78. package/lib/session-query-readers.js +263 -0
  79. package/lib/session-surface-compat.js +30 -31
  80. package/lib/session-surface-policy-domain.js +85 -0
  81. package/lib/session-surface-policy.js +2 -111
  82. package/lib/session-turn-resolver.js +2 -6
  83. package/lib/session-vision-attachment-index.js +143 -0
  84. package/lib/session-vision-delegation-boundary.js +34 -0
  85. package/lib/session-vision-delegation.js +22 -0
  86. package/lib/session-vision-index.js +179 -116
  87. package/lib/session-vision-mode-authority.js +229 -192
  88. package/lib/session-vision-mode-boundary.js +65 -22
  89. package/lib/session-vision-policy-domain.js +304 -0
  90. package/lib/session-vision-policy.js +104 -0
  91. package/lib/session-vision-state.js +368 -378
  92. package/lib/settings-client-017-compat.js +9 -7
  93. package/lib/settings-client-loader-lifecycle.js +129 -0
  94. package/lib/sharp-runtime.js +0 -7
  95. package/lib/structured-flow-hardening.js +25 -6
  96. package/lib/tesseract-exec-compat.js +9 -1
  97. package/lib/turn-budget-context.js +47 -41
  98. package/lib/update-check.js +11 -2
  99. package/lib/vision-attachment-handle-runtime.js +176 -156
  100. package/lib/vision-backend-runtime-policy.js +19 -2
  101. package/lib/vision-background-benchmark.js +41 -4
  102. package/lib/vision-background-failure-policy.js +41 -41
  103. package/lib/vision-background-stop-store-core.js +217 -0
  104. package/lib/vision-background-stop-store.js +8 -179
  105. package/lib/vision-capability-benchmark-service.js +1 -0
  106. package/lib/vision-capability-benchmark.js +11 -61
  107. package/lib/vision-capability-evidence.js +35 -0
  108. package/lib/vision-capability-fingerprint.js +72 -0
  109. package/lib/vision-capability-identity.js +116 -61
  110. package/lib/vision-capability-router.js +13 -33
  111. package/lib/vision-chunk-assembler.js +95 -0
  112. package/lib/vision-diagnostics-routes.js +246 -0
  113. package/lib/vision-evidence-guidance.js +3 -1
  114. package/lib/vision-execution-order-apply.js +45 -55
  115. package/lib/vision-execution-order.js +19 -19
  116. package/lib/vision-execution-policy.js +12 -7
  117. package/lib/vision-model-registry.js +30 -17
  118. package/lib/vision-provider-transport.js +137 -176
  119. package/lib/vision-routing-authority.js +47 -48
  120. package/lib/vision-routing-product.js +26 -29
  121. package/lib/vision-routing-settings-prelude.js +8 -3
  122. package/lib/vision-runtime-performance-store.js +166 -0
  123. package/lib/vision-runtime-performance.js +13 -142
  124. package/lib/vision-toggle-root-hardening.js +22 -5
  125. package/lib/vision-tool-runtime-boundary.js +104 -31
  126. package/lib/web/dsh-settings-017-root-transport.js +27 -12
  127. package/lib/web/remote-settings-client.js +6 -9
  128. package/lib/web-capability-boundary.js +17 -7
  129. package/lib/wrapper-directory.js +14 -23
  130. package/package.json +56 -20
  131. package/lib/android-attachment-compat.js +0 -244
  132. package/lib/settings-client-rc8-lifecycle.js +0 -397
@@ -1,4 +1,3 @@
1
- import { currentVisionProviderTransport } from './vision-provider-transport.js'
2
1
 
3
2
  const GLM_4V_FLASH = /^glm-4v-flash(?:$|[-_.])/i
4
3
  export const DEFAULT_HTTP_ERROR_DETAIL_MAX_BYTES = 64 * 1024
@@ -278,7 +277,7 @@ function nextCompatibilityBody(body, detail) {
278
277
  */
279
278
  export async function fetchWithOpenAICompatibility(fetchImpl, input, init, context = {}) {
280
279
  if (typeof fetchImpl !== 'function') throw new TypeError('fetch implementation is required')
281
- const transport = context.active === true ? currentVisionProviderTransport() : undefined
280
+ const transport = context.active === true ? context.providerTransport : undefined
282
281
  const requestFetch = transport
283
282
  ? (nextInput, nextInit) => transport.fetch(nextInput, nextInit, {
284
283
  providerName: context.providerName,
@@ -1,25 +1,34 @@
1
+ function objectRecord(value) {
2
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
3
+ ? value
4
+ : undefined;
5
+ }
1
6
  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)
7
+ const attachment = objectRecord(objectRecord(block)?.attachment);
8
+ const raw = attachment?.attachmentId ?? attachment?.id;
9
+ if (typeof raw !== 'string' || raw.trim() === '')
10
+ return undefined;
11
+ return raw.trim().slice(0, 256);
6
12
  }
7
-
8
13
  /** DSH 0.1.6+ marks durable request-limit omissions on the ImageBlock itself. */
9
14
  export function isOffloadedImageBlock(block) {
10
- return !!block && block.type === 'image' && block.offloaded === true
15
+ const value = objectRecord(block);
16
+ return value?.type === 'image' && value.offloaded === true;
11
17
  }
12
-
13
18
  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
19
+ if (!Array.isArray(content))
20
+ return false;
21
+ for (const block of content) {
22
+ const value = objectRecord(block);
23
+ if (value === undefined)
24
+ continue;
25
+ if (value.type === 'image' && value.offloaded !== true)
26
+ return true;
27
+ if (Array.isArray(value.content) && blocksHaveRetainedImage(value.content))
28
+ return true;
29
+ }
30
+ return false;
21
31
  }
22
-
23
32
  /**
24
33
  * Dependency-free model-facing placeholder for an image DSH already offloaded.
25
34
  *
@@ -29,10 +38,10 @@ export function blocksHaveRetainedImage(content) {
29
38
  * read or resend its bytes once the Host projection marks this occurrence.
30
39
  */
31
40
  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)}].]`
41
+ const id = boundedAttachmentId(block);
42
+ if (id === undefined) {
43
+ return '[image omitted to fit request image limits; attachment id unavailable]';
44
+ }
45
+ return `[image omitted to fit request image limits; attachment ${id}. ` +
46
+ `To inspect it again, call vision_describe with attachmentIds: [${JSON.stringify(id)}].]`;
38
47
  }
@@ -1,254 +1,236 @@
1
- import { currentVisionTurnBudgetSignal } from './turn-budget-context.js'
2
-
3
- const MIB = 1024 * 1024
4
- export const DEFAULT_IMAGE_RESOURCE_MAX_BYPASSES = 2
5
-
1
+ import { currentVisionTurnBudgetSignal } from './turn-budget-context.js';
2
+ const MIB = 1024 * 1024;
3
+ export const DEFAULT_IMAGE_RESOURCE_MAX_BYPASSES = 2;
6
4
  function abortError() {
7
- const error = new Error('image resource wait aborted')
8
- error.name = 'AbortError'
9
- error.code = 'ABORT_ERR'
10
- return error
5
+ const error = new Error('image resource wait aborted');
6
+ error.name = 'AbortError';
7
+ error.code = 'ABORT_ERR';
8
+ return error;
11
9
  }
12
-
13
10
  function effectiveSignal(explicit) {
14
- const ambient = currentVisionTurnBudgetSignal()
15
- if (!explicit) return ambient
16
- if (!ambient || ambient === explicit) return explicit
17
- return AbortSignal.any([explicit, ambient])
11
+ const ambient = currentVisionTurnBudgetSignal();
12
+ if (!explicit)
13
+ return ambient;
14
+ if (!ambient || ambient === explicit)
15
+ return explicit;
16
+ return AbortSignal.any([explicit, ambient]);
18
17
  }
19
-
20
- export function estimateDecodedBytes(
21
- width,
22
- height,
23
- { channels = 4, copies = 1, safetyFactor = 1.25 } = {},
24
- ) {
25
- const w = Math.max(0, Math.floor(Number(width) || 0))
26
- const h = Math.max(0, Math.floor(Number(height) || 0))
27
- const c = Math.max(1, Number(channels) || 4)
28
- const n = Math.max(1, Number(copies) || 1)
29
- const factor = Math.max(1, Number(safetyFactor) || 1)
30
- return Math.ceil(w * h * c * n * factor)
18
+ export function estimateDecodedBytes(width, height, { channels = 4, copies = 1, safetyFactor = 1.25, } = {}) {
19
+ const w = Math.max(0, Math.floor(Number(width) || 0));
20
+ const h = Math.max(0, Math.floor(Number(height) || 0));
21
+ const c = Math.max(1, Number(channels) || 4);
22
+ const n = Math.max(1, Number(copies) || 1);
23
+ const factor = Math.max(1, Number(safetyFactor) || 1);
24
+ return Math.ceil(w * h * c * n * factor);
31
25
  }
32
-
33
26
  export function estimateImageOperationBytes(operation, width, height) {
34
- switch (operation) {
35
- case 'metadata':
36
- return 1 * MIB
37
- case 'preview':
38
- case 'annotation':
39
- return estimateDecodedBytes(width, height, { copies: 1, safetyFactor: 1.5 })
40
- case 'tile':
41
- case 'crop':
42
- return estimateDecodedBytes(width, height, { copies: 1, safetyFactor: 1.35 })
43
- case 'raw':
44
- return estimateDecodedBytes(width, height, { copies: 2, safetyFactor: 1.5 })
45
- case 'pixel-diff':
46
- return estimateDecodedBytes(width, height, { copies: 3, safetyFactor: 1.5 })
47
- default:
48
- return estimateDecodedBytes(width, height, { copies: 1, safetyFactor: 1.5 })
49
- }
27
+ switch (operation) {
28
+ case 'metadata':
29
+ return 1 * MIB;
30
+ case 'preview':
31
+ case 'annotation':
32
+ return estimateDecodedBytes(width, height, { copies: 1, safetyFactor: 1.5 });
33
+ case 'tile':
34
+ case 'crop':
35
+ return estimateDecodedBytes(width, height, { copies: 1, safetyFactor: 1.35 });
36
+ case 'raw':
37
+ return estimateDecodedBytes(width, height, { copies: 2, safetyFactor: 1.5 });
38
+ case 'pixel-diff':
39
+ return estimateDecodedBytes(width, height, { copies: 3, safetyFactor: 1.5 });
40
+ default:
41
+ return estimateDecodedBytes(width, height, { copies: 1, safetyFactor: 1.5 });
42
+ }
50
43
  }
51
-
52
44
  export function scaledDimensions(width, height, maxPixels) {
53
- const w = Math.max(0, Math.floor(Number(width) || 0))
54
- const h = Math.max(0, Math.floor(Number(height) || 0))
55
- const limit = Math.max(1, Math.floor(Number(maxPixels) || 1))
56
- if (w <= 0 || h <= 0) return { width: w, height: h, scale: 1 }
57
- const pixels = w * h
58
- if (pixels <= limit) return { width: w, height: h, scale: 1 }
59
- const scale = Math.sqrt(limit / pixels)
60
- return {
61
- width: Math.max(1, Math.floor(w * scale)),
62
- height: Math.max(1, Math.floor(h * scale)),
63
- scale,
64
- }
45
+ const w = Math.max(0, Math.floor(Number(width) || 0));
46
+ const h = Math.max(0, Math.floor(Number(height) || 0));
47
+ const limit = Math.max(1, Math.floor(Number(maxPixels) || 1));
48
+ if (w <= 0 || h <= 0)
49
+ return { width: w, height: h, scale: 1 };
50
+ const pixels = w * h;
51
+ if (pixels <= limit)
52
+ return { width: w, height: h, scale: 1 };
53
+ const scale = Math.sqrt(limit / pixels);
54
+ return {
55
+ width: Math.max(1, Math.floor(w * scale)),
56
+ height: Math.max(1, Math.floor(h * scale)),
57
+ scale,
58
+ };
65
59
  }
66
-
67
60
  export function scaleBox(box, fromWidth, fromHeight, toWidth, toHeight) {
68
- const sx = fromWidth > 0 ? toWidth / fromWidth : 1
69
- const sy = fromHeight > 0 ? toHeight / fromHeight : 1
70
- return {
71
- x1: Math.max(0, Math.round(box.x1 * sx)),
72
- y1: Math.max(0, Math.round(box.y1 * sy)),
73
- x2: Math.min(toWidth, Math.max(1, Math.round(box.x2 * sx))),
74
- y2: Math.min(toHeight, Math.max(1, Math.round(box.y2 * sy))),
75
- }
61
+ const sx = fromWidth > 0 ? toWidth / fromWidth : 1;
62
+ const sy = fromHeight > 0 ? toHeight / fromHeight : 1;
63
+ return {
64
+ x1: Math.max(0, Math.round(box.x1 * sx)),
65
+ y1: Math.max(0, Math.round(box.y1 * sy)),
66
+ x2: Math.min(toWidth, Math.max(1, Math.round(box.x2 * sx))),
67
+ y2: Math.min(toHeight, Math.max(1, Math.round(box.y2 * sy))),
68
+ };
76
69
  }
77
-
78
70
  /**
79
71
  * Cover a long/large image with bounded tiles in reading order. Normal narrow
80
72
  * screenshots keep one full-width strip. Ultra-wide inputs split horizontally
81
73
  * as well so width * height never exceeds maxTilePixels.
82
74
  */
83
- export function boundedOcrTiles(
84
- width,
85
- height,
86
- { chunkHeight = 1200, overlap = 120, maxTilePixels = 4_000_000 } = {},
87
- ) {
88
- const w = Math.max(1, Math.floor(Number(width) || 1))
89
- const h = Math.max(1, Math.floor(Number(height) || 1))
90
- const requestedHeight = Math.max(1, Math.min(h, Math.floor(Number(chunkHeight) || 1200)))
91
- const pixelLimit = Math.max(1, Math.floor(Number(maxTilePixels) || 4_000_000))
92
- const tileWidth = Math.min(w, Math.max(1, Math.floor(pixelLimit / requestedHeight)))
93
- const tileHeight = Math.min(requestedHeight, Math.max(1, Math.floor(pixelLimit / tileWidth)))
94
- const verticalOverlap = Math.min(
95
- Math.max(0, Math.floor(Number(overlap) || 0)),
96
- Math.max(0, Math.floor(tileHeight / 2)),
97
- )
98
- const verticalStep = Math.max(1, tileHeight - verticalOverlap)
99
- const tiles = []
100
- for (let top = 0; top < h; top += verticalStep) {
101
- const bottom = Math.min(h, top + tileHeight)
102
- for (let left = 0; left < w; left += tileWidth) {
103
- const right = Math.min(w, left + tileWidth)
104
- tiles.push({ left, right, top, bottom })
75
+ export function boundedOcrTiles(width, height, { chunkHeight = 1200, overlap = 120, maxTilePixels = 4_000_000, } = {}) {
76
+ const w = Math.max(1, Math.floor(Number(width) || 1));
77
+ const h = Math.max(1, Math.floor(Number(height) || 1));
78
+ const requestedHeight = Math.max(1, Math.min(h, Math.floor(Number(chunkHeight) || 1200)));
79
+ const pixelLimit = Math.max(1, Math.floor(Number(maxTilePixels) || 4_000_000));
80
+ const tileWidth = Math.min(w, Math.max(1, Math.floor(pixelLimit / requestedHeight)));
81
+ const tileHeight = Math.min(requestedHeight, Math.max(1, Math.floor(pixelLimit / tileWidth)));
82
+ const verticalOverlap = Math.min(Math.max(0, Math.floor(Number(overlap) || 0)), Math.max(0, Math.floor(tileHeight / 2)));
83
+ const verticalStep = Math.max(1, tileHeight - verticalOverlap);
84
+ const tiles = [];
85
+ for (let top = 0; top < h; top += verticalStep) {
86
+ const bottom = Math.min(h, top + tileHeight);
87
+ for (let left = 0; left < w; left += tileWidth) {
88
+ const right = Math.min(w, left + tileWidth);
89
+ tiles.push({ left, right, top, bottom });
90
+ }
91
+ if (bottom >= h)
92
+ break;
105
93
  }
106
- if (bottom >= h) break
107
- }
108
- return tiles
94
+ return tiles;
109
95
  }
110
-
111
96
  export class ImageResourceGovernor {
112
- constructor({
113
- maxBytes = 256 * MIB,
114
- maxConcurrent = 2,
115
- maxBypasses = DEFAULT_IMAGE_RESOURCE_MAX_BYPASSES,
116
- } = {}) {
117
- this.maxBytes = Math.max(1, Math.floor(Number(maxBytes) || 256 * MIB))
118
- this.maxConcurrent = Math.max(1, Math.floor(Number(maxConcurrent) || 2))
119
- this.maxBypasses = Math.max(0, Math.floor(Number(maxBypasses) || 0))
120
- this.activeBytes = 0
121
- this.activeCount = 0
122
- this.activeExclusive = false
123
- this.queue = []
124
- }
125
-
126
- _normalize(bytes, exclusive) {
127
- const requested = Math.max(1, Math.ceil(Number(bytes) || 1))
128
- const wantsExclusive = exclusive === true || requested >= this.maxBytes
129
- return {
130
- requested,
131
- charged: Math.min(requested, this.maxBytes),
132
- exclusive: wantsExclusive,
97
+ maxBytes;
98
+ maxConcurrent;
99
+ maxBypasses;
100
+ activeBytes = 0;
101
+ activeCount = 0;
102
+ activeExclusive = false;
103
+ queue = [];
104
+ constructor({ maxBytes = 256 * MIB, maxConcurrent = 2, maxBypasses = DEFAULT_IMAGE_RESOURCE_MAX_BYPASSES, } = {}) {
105
+ this.maxBytes = Math.max(1, Math.floor(Number(maxBytes) || 256 * MIB));
106
+ this.maxConcurrent = Math.max(1, Math.floor(Number(maxConcurrent) || 2));
107
+ this.maxBypasses = Math.max(0, Math.floor(Number(maxBypasses) || 0));
133
108
  }
134
- }
135
-
136
- _canRun(request) {
137
- if (this.activeExclusive) return false
138
- if (request.exclusive) return this.activeCount === 0
139
- return (
140
- this.activeCount < this.maxConcurrent &&
141
- this.activeBytes + request.charged <= this.maxBytes
142
- )
143
- }
144
-
145
- _grant(item) {
146
- if (item.settled) return
147
- item.settled = true
148
- if (item.signal && item.abortHandler) {
149
- item.signal.removeEventListener('abort', item.abortHandler)
109
+ _normalize(bytes, exclusive) {
110
+ const requested = Math.max(1, Math.ceil(Number(bytes) || 1));
111
+ const wantsExclusive = exclusive === true || requested >= this.maxBytes;
112
+ return {
113
+ requested,
114
+ charged: Math.min(requested, this.maxBytes),
115
+ exclusive: wantsExclusive,
116
+ };
150
117
  }
151
- this.activeCount += 1
152
- this.activeBytes += item.request.charged
153
- if (item.request.exclusive) this.activeExclusive = true
154
- let released = false
155
- item.resolve(() => {
156
- if (released) return
157
- released = true
158
- this.activeCount = Math.max(0, this.activeCount - 1)
159
- this.activeBytes = Math.max(0, this.activeBytes - item.request.charged)
160
- if (item.request.exclusive) this.activeExclusive = false
161
- this._drain()
162
- })
163
- }
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)
118
+ _canRun(request) {
119
+ if (this.activeExclusive)
120
+ return false;
121
+ if (request.exclusive)
122
+ return this.activeCount === 0;
123
+ return (this.activeCount < this.maxConcurrent &&
124
+ this.activeBytes + request.charged <= this.maxBytes);
180
125
  }
181
- return undefined
182
- }
183
-
184
- _drain() {
185
- while (this.queue.length > 0) {
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
126
+ _grant(item) {
127
+ if (item.settled)
128
+ return;
129
+ item.settled = true;
130
+ if (item.signal && item.abortHandler) {
131
+ item.signal.removeEventListener('abort', item.abortHandler);
132
+ }
133
+ this.activeCount += 1;
134
+ this.activeBytes += item.request.charged;
135
+ if (item.request.exclusive)
136
+ this.activeExclusive = true;
137
+ let released = false;
138
+ item.resolve(() => {
139
+ if (released)
140
+ return;
141
+ released = true;
142
+ this.activeCount = Math.max(0, this.activeCount - 1);
143
+ this.activeBytes = Math.max(0, this.activeBytes - item.request.charged);
144
+ if (item.request.exclusive)
145
+ this.activeExclusive = false;
146
+ this._drain();
147
+ });
194
148
  }
195
- }
196
-
197
- acquire(bytes, { signal, exclusive = false } = {}) {
198
- // Structured 1+x installs its remaining turn deadline in AsyncLocalStorage.
199
- // Even image helpers that historically passed `{}` now inherit that signal
200
- // while they wait in the resource queue, so an exhausted turn cannot leave
201
- // dead work queued behind another large image operation.
202
- const combinedSignal = effectiveSignal(signal)
203
- if (combinedSignal?.aborted) return Promise.reject(abortError())
204
- const request = this._normalize(bytes, exclusive)
205
- return new Promise((resolve, reject) => {
206
- const item = {
207
- request,
208
- signal: combinedSignal,
209
- resolve,
210
- reject,
211
- settled: false,
212
- abortHandler: undefined,
213
- bypassCount: 0,
214
- }
215
- item.abortHandler = () => {
216
- if (item.settled) return
217
- item.settled = true
218
- const index = this.queue.indexOf(item)
219
- if (index >= 0) this.queue.splice(index, 1)
220
- reject(abortError())
221
- this._drain()
222
- }
223
- if (combinedSignal) combinedSignal.addEventListener('abort', item.abortHandler, { once: true })
224
- if (this.queue.length === 0 && this._canRun(request)) {
225
- this._grant(item)
226
- } else {
227
- this.queue.push(item)
228
- this._drain()
229
- }
230
- })
231
- }
232
-
233
- async withBudget(bytes, options, fn) {
234
- const release = await this.acquire(bytes, options)
235
- try {
236
- return await fn()
237
- } finally {
238
- release()
149
+ _nextRunnable() {
150
+ const bypassed = [];
151
+ for (let index = 0; index < this.queue.length; index++) {
152
+ const item = this.queue[index];
153
+ if (!item || item.settled)
154
+ continue;
155
+ if (this._canRun(item.request))
156
+ return { index, item, bypassed };
157
+ if (item.request.exclusive || item.bypassCount >= this.maxBypasses)
158
+ return undefined;
159
+ bypassed.push(item);
160
+ }
161
+ return undefined;
239
162
  }
240
- }
241
-
242
- stats() {
243
- return {
244
- maxBytes: this.maxBytes,
245
- maxConcurrent: this.maxConcurrent,
246
- activeBytes: this.activeBytes,
247
- activeCount: this.activeCount,
248
- queued: this.queue.filter((item) => !item.settled).length,
249
- exclusive: this.activeExclusive,
163
+ _drain() {
164
+ while (this.queue.length > 0) {
165
+ while (this.queue[0]?.settled)
166
+ this.queue.shift();
167
+ if (this.queue.length === 0)
168
+ return;
169
+ const selected = this._nextRunnable();
170
+ if (!selected)
171
+ return;
172
+ this.queue.splice(selected.index, 1);
173
+ for (const item of selected.bypassed)
174
+ item.bypassCount += 1;
175
+ this._grant(selected.item);
176
+ if (selected.item.request.exclusive)
177
+ return;
178
+ }
179
+ }
180
+ acquire(bytes, { signal, exclusive = false } = {}) {
181
+ const combinedSignal = effectiveSignal(signal);
182
+ if (combinedSignal?.aborted)
183
+ return Promise.reject(abortError());
184
+ const request = this._normalize(bytes, exclusive);
185
+ return new Promise((resolve, reject) => {
186
+ const item = {
187
+ request,
188
+ signal: combinedSignal,
189
+ resolve,
190
+ reject,
191
+ settled: false,
192
+ abortHandler: undefined,
193
+ bypassCount: 0,
194
+ };
195
+ item.abortHandler = () => {
196
+ if (item.settled)
197
+ return;
198
+ item.settled = true;
199
+ const index = this.queue.indexOf(item);
200
+ if (index >= 0)
201
+ this.queue.splice(index, 1);
202
+ reject(abortError());
203
+ this._drain();
204
+ };
205
+ if (combinedSignal)
206
+ combinedSignal.addEventListener('abort', item.abortHandler, { once: true });
207
+ if (this.queue.length === 0 && this._canRun(request)) {
208
+ this._grant(item);
209
+ }
210
+ else {
211
+ this.queue.push(item);
212
+ this._drain();
213
+ }
214
+ });
215
+ }
216
+ async withBudget(bytes, options, fn) {
217
+ const release = await this.acquire(bytes, options);
218
+ try {
219
+ return await fn();
220
+ }
221
+ finally {
222
+ release();
223
+ }
224
+ }
225
+ stats() {
226
+ return {
227
+ maxBytes: this.maxBytes,
228
+ maxConcurrent: this.maxConcurrent,
229
+ activeBytes: this.activeBytes,
230
+ activeCount: this.activeCount,
231
+ queued: this.queue.filter((item) => !item.settled).length,
232
+ exclusive: this.activeExclusive,
233
+ };
250
234
  }
251
- }
252
235
  }
253
-
254
- export const defaultImageResourceGovernor = new ImageResourceGovernor()
236
+ export const defaultImageResourceGovernor = new ImageResourceGovernor();
@@ -15,6 +15,11 @@ import {
15
15
  } from './proxy-routing.js'
16
16
 
17
17
  const legacyProxyScope = new AsyncLocalStorage()
18
+ // The one process-wide fetch this boundary currently owns. Consumers that must
19
+ // follow the same egress path ask for it here instead of reading `globalThis.fetch`
20
+ // at call time (which the lifecycle invariant forbids and which would re-admit
21
+ // whatever wrapper happens to be installed later).
22
+ let installedScopedFetch
18
23
 
19
24
  export const LEGACY_GLOBAL_PROXY_REMOVAL_CONDITION =
20
25
  'Remove the remaining wrapper when explicit Vision Router proxy overrides no longer need Host-owned adapter interception and the legacy direct whole-turn fallback is retired by product policy.'
@@ -86,6 +91,36 @@ export function legacyGlobalProxyScopeAllows(config = {}, state = currentLegacyG
86
91
  return configuredHostOwnedPair(config, state.provider, state.model)
87
92
  }
88
93
 
94
+ // Probe paths (live model discovery, the settings "test connection" route) do not
95
+ // stream a provider turn, so they have no adapter call to open a scope for them.
96
+ // Without a scope, `scopedFetch` falls through to the original fetch and a
97
+ // configured `proxy` silently does not apply — while real vision turns to the same
98
+ // provider are proxied. Expose the exact pair lookup and a plain-promise scope
99
+ // runner so those probes follow the same egress path as the traffic they describe.
100
+ export function currentLegacyGlobalProxyFetch() {
101
+ return installedScopedFetch
102
+ }
103
+
104
+ export function legacyGlobalProxyPairFor(config = {}, provider) {
105
+ if (!visionProxyOverrideConfigured(config) || typeof provider !== 'string' || provider === '') return undefined
106
+ return configuredPairs(config).find(
107
+ (pair) => pair.provider === provider && !routerOwnedProvider(pair.provider, config),
108
+ )
109
+ }
110
+
111
+ export function runWithLegacyGlobalProxyScope(provider, model, work) {
112
+ if (typeof work !== 'function') throw new TypeError('legacy proxy scope requires work')
113
+ if (!validPair(provider, model)) return work()
114
+ const state = { provider, model, active: true }
115
+ return legacyProxyScope.run(state, async () => {
116
+ try {
117
+ return await work()
118
+ } finally {
119
+ state.active = false
120
+ }
121
+ })
122
+ }
123
+
89
124
  // AsyncIterable work is lazy: the adapter normally opens its network request on
90
125
  // iterator.next(), not when ctx.llm.stream() returns. Keep the scope active for
91
126
  // every iterator operation and retire it as soon as the stream ends/fails.
@@ -229,19 +264,27 @@ export function installLegacyGlobalProxyBoundary(ctx, config = {}, options = {})
229
264
  }
230
265
 
231
266
  const restoreFetch = installFetchWrapper(scopedFetch)
267
+ installedScopedFetch = scopedFetch
232
268
  let disposed = false
269
+ let disposePromise
233
270
  const dispose = () => {
234
- if (disposed) return
271
+ if (disposePromise) return disposePromise
272
+ if (disposed) return Promise.resolve()
235
273
  disposed = true
236
274
  active = false
237
- void dispatcherPool.dispose()
275
+ if (installedScopedFetch === scopedFetch) installedScopedFetch = undefined
276
+ // Process-wide fetch ownership is released synchronously; the returned
277
+ // Promise owns only DVR's private dispatcher retirement and is awaitable by
278
+ // the Cordis fiber during HMR/unload.
238
279
  restoreFetch()
280
+ disposePromise = dispatcherPool.dispose()
281
+ return disposePromise
239
282
  }
240
283
  if (typeof ctx?.effect === 'function') {
241
284
  try {
242
285
  ctx.effect(() => dispose, 'vision-router: scoped legacy proxy fetch')
243
286
  } catch (error) {
244
- dispose()
287
+ void dispose().catch(() => {})
245
288
  throw error
246
289
  }
247
290
  }
@@ -503,12 +503,20 @@ export const LIVE_MODEL_CLIENT_PRELUDE = String.raw`(function(){
503
503
  }
504
504
  });
505
505
  if (contexts) contexts.set(ctx, wrapped);
506
+ var disposeLifecycle = function() {
507
+ live.dispose();
508
+ if (contexts && contexts.get(ctx) === wrapped) contexts.delete(ctx);
509
+ };
510
+ var lifecycleOwned = false;
506
511
  try {
507
- if (typeof ctx.effect === 'function') ctx.effect(function(){ return function(){ live.dispose(); }; }, 'vision-router: live model catalog client');
512
+ if (typeof ctx.effect === 'function') {
513
+ ctx.effect(function(){ return disposeLifecycle; }, 'vision-router: live model catalog client');
514
+ lifecycleOwned = true;
515
+ }
508
516
  } catch (_) {}
509
- // Warm the Host cache as soon as the plugin activates. By the time the user
510
- // opens Settings, stale-while-revalidate normally has a snapshot ready.
511
- void live.refresh(true);
517
+ if (!lifecycleOwned) disposeLifecycle();
518
+ // Warm the Host cache only after Cordis owns this client generation.
519
+ if (lifecycleOwned) void live.refresh(true);
512
520
  return wrapped;
513
521
  }
514
522