dsh-vision-router 2.3.0 → 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 (103) hide show
  1. package/README.md +12 -3
  2. package/README.zh.md +12 -3
  3. package/docs/architecture/3.0-architecture-plan.md +252 -0
  4. package/docs/architecture/3.0-r0-contract-ledger.md +137 -0
  5. package/docs/architecture/3.0-r1-source-artifact-boundary.md +98 -0
  6. package/docs/architecture/3.0-r10-final-correctness-closure.md +207 -0
  7. package/docs/architecture/3.0-r11-adversarial-review.md +441 -0
  8. package/docs/architecture/3.0-r5-browser-go-no-go.md +165 -0
  9. package/docs/architecture/3.0-r5-browser-typescript-gate.md +105 -0
  10. package/docs/architecture/3.0-r6-compat-retirement.md +203 -0
  11. package/docs/architecture/3.0-r7-public-api.md +151 -0
  12. package/docs/architecture/3.0-r8-ci-release.md +248 -0
  13. package/docs/architecture/3.0-r9-final-audit.md +370 -0
  14. package/docs/architecture/dsh-support-window.md +5 -3
  15. package/docs/releases/v3.0.0.md +85 -0
  16. package/entry.js +1 -1
  17. package/index.js +108 -71
  18. package/lib/agent-request-route-authority.js +1 -20
  19. package/lib/core-apply-lifecycle.js +37 -43
  20. package/lib/core-cache.js +87 -0
  21. package/lib/core-primitives.js +3 -143
  22. package/lib/core-vision-surface-domain.js +63 -0
  23. package/lib/core-vision-surface.js +11 -77
  24. package/lib/delegated-call-config.js +15 -24
  25. package/lib/doctor-cli.js +0 -0
  26. package/lib/doctor-runtime.js +4 -1
  27. package/lib/dsh-contract-compat.js +26 -230
  28. package/lib/dsh-host-capabilities.js +3 -9
  29. package/lib/dsh-settings-017-compat.js +17 -10
  30. package/lib/file-logger.js +8 -3
  31. package/lib/grounding-coordinate-runtime.js +1 -2
  32. package/lib/http-body-limit.js +97 -79
  33. package/lib/image-offload-compat.js +30 -21
  34. package/lib/image-resource-governor.js +210 -228
  35. package/lib/legacy-global-proxy-boundary.js +46 -3
  36. package/lib/live-model-client-prelude.js +12 -4
  37. package/lib/live-model-discovery.js +134 -43
  38. package/lib/local-vision-stabilizer.js +31 -17
  39. package/lib/maintenance-routes.js +1 -3
  40. package/lib/native-image-coexistence.js +15 -3
  41. package/lib/ollama-cold-start.js +87 -23
  42. package/lib/pi-ai-bridge-wire-compat.js +3 -2
  43. package/lib/proxy-dispatcher-pool.js +151 -92
  44. package/lib/proxy-routing.js +75 -51
  45. package/lib/proxy-url-compat.js +3 -10
  46. package/lib/public-config.js +23 -44
  47. package/lib/public-entry.d.ts +148 -0
  48. package/lib/public-entry.js +17 -4
  49. package/lib/replay-delegation.js +12 -7
  50. package/lib/replay-envelope-v2-compat.js +12 -7
  51. package/lib/runtime-composition.js +22 -4
  52. package/lib/screenshot-source-boundary.js +2 -2
  53. package/lib/session-affinity-runtime.js +113 -97
  54. package/lib/session-affinity.js +89 -76
  55. package/lib/session-event-history-compat.js +30 -0
  56. package/lib/session-query-readers.js +263 -0
  57. package/lib/session-surface-compat.js +30 -31
  58. package/lib/session-surface-policy-domain.js +85 -0
  59. package/lib/session-surface-policy.js +2 -111
  60. package/lib/session-turn-resolver.js +2 -6
  61. package/lib/session-vision-attachment-index.js +143 -0
  62. package/lib/session-vision-delegation-boundary.js +34 -0
  63. package/lib/session-vision-delegation.js +22 -0
  64. package/lib/session-vision-index.js +25 -107
  65. package/lib/session-vision-mode-authority.js +229 -192
  66. package/lib/session-vision-mode-boundary.js +65 -22
  67. package/lib/session-vision-policy-domain.js +304 -0
  68. package/lib/session-vision-policy.js +104 -0
  69. package/lib/session-vision-state.js +368 -378
  70. package/lib/settings-client-017-compat.js +9 -7
  71. package/lib/structured-flow-hardening.js +22 -4
  72. package/lib/tesseract-exec-compat.js +9 -1
  73. package/lib/turn-budget-context.js +47 -41
  74. package/lib/update-check.js +11 -2
  75. package/lib/vision-attachment-handle-runtime.js +176 -156
  76. package/lib/vision-backend-runtime-policy.js +16 -0
  77. package/lib/vision-background-benchmark.js +41 -4
  78. package/lib/vision-background-failure-policy.js +41 -41
  79. package/lib/vision-background-stop-store-core.js +217 -0
  80. package/lib/vision-background-stop-store.js +8 -179
  81. package/lib/vision-capability-benchmark.js +11 -61
  82. package/lib/vision-capability-evidence.js +35 -0
  83. package/lib/vision-capability-fingerprint.js +72 -0
  84. package/lib/vision-capability-identity.js +116 -61
  85. package/lib/vision-capability-router.js +13 -33
  86. package/lib/vision-chunk-assembler.js +95 -0
  87. package/lib/vision-diagnostics-routes.js +34 -4
  88. package/lib/vision-execution-order-apply.js +45 -55
  89. package/lib/vision-execution-order.js +19 -19
  90. package/lib/vision-execution-policy.js +12 -7
  91. package/lib/vision-model-registry.js +30 -17
  92. package/lib/vision-provider-transport.js +137 -147
  93. package/lib/vision-routing-authority.js +47 -48
  94. package/lib/vision-routing-product.js +26 -29
  95. package/lib/vision-routing-settings-prelude.js +8 -3
  96. package/lib/vision-runtime-performance-store.js +166 -0
  97. package/lib/vision-runtime-performance.js +13 -142
  98. package/lib/vision-tool-runtime-boundary.js +99 -30
  99. package/lib/web/dsh-settings-017-root-transport.js +27 -12
  100. package/lib/web/remote-settings-client.js +4 -7
  101. package/lib/web-capability-boundary.js +17 -7
  102. package/lib/wrapper-directory.js +14 -23
  103. package/package.json +37 -11
@@ -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