dsh-vision-router 2.1.3 → 2.1.4

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.
@@ -16,6 +16,7 @@ import {
16
16
  } from './http-body-limit.js'
17
17
  import { stripTrailingSlashes } from './string-normalization.js'
18
18
  import { currentVisionProviderTransport } from './vision-provider-transport.js'
19
+ import { directSessionAffinityHeaders } from './session-affinity.js'
19
20
 
20
21
  export const CATALOG_ROUTING_CORRECTIONS = [
21
22
  {
@@ -240,6 +241,7 @@ export async function callAnthropicCompatible(provider, messages, options = {})
240
241
  'anthropic-version': '2023-06-01',
241
242
  // Local keyless servers reject an empty x-api-key; omit it when allowed.
242
243
  ...(resolvedApiKey === '' ? {} : { 'x-api-key': resolvedApiKey }),
244
+ ...directSessionAffinityHeaders(provider, options.affinityId ?? options.sessionId),
243
245
  }
244
246
  const body = {
245
247
  model: provider.model,
@@ -52,6 +52,109 @@ const VISION_TOGGLE_FIXED_SVG = String.raw` React.createElement('svg',
52
52
  }))
53
53
  : null`
54
54
 
55
+ const BITMAP_LIMITS_BASE = String.raw` var maxBitmapSourceBytes = 64 * 1024 * 1024;
56
+ var maxBitmapDimension = 10000;
57
+ var maxBitmapPixels = 100000000;`
58
+
59
+ const BITMAP_LIMITS_HARDENED = String.raw` var maxBitmapSourceBytes = 64 * 1024 * 1024;
60
+ var maxBitmapDimension = 10000;
61
+ var maxBitmapPixels = 100000000;
62
+ // Source bytes are not a safe proxy for browser decode cost. A low-bit-depth
63
+ // BMP can be only a few MiB on the clipboard and still expand to hundreds
64
+ // of MiB once createImageBitmap/canvas materializes RGBA pixels.
65
+ var maxBitmapDecodedBytes = 128 * 1024 * 1024;
66
+ var maxBitmapBatchDecodedBytes = 256 * 1024 * 1024;`
67
+
68
+ const BITMAP_DECODE_ALLOWED_BASE = String.raw` function bitmapDecodeAllowed(file, info) {
69
+ if (!file || !info) return false;
70
+ if (!Number.isFinite(file.size) || file.size <= 0 || file.size > maxBitmapSourceBytes) return false;
71
+ if (!Number.isFinite(info.width) || !Number.isFinite(info.height) || info.width <= 0 || info.height <= 0) return false;
72
+ if (info.width > maxBitmapDimension || info.height > maxBitmapDimension) return false;
73
+ return info.width * info.height <= maxBitmapPixels;
74
+ }`
75
+
76
+ const BITMAP_DECODE_ALLOWED_HARDENED = String.raw` function bitmapDecodeAllowed(file, info) {
77
+ if (!file || !info) return false;
78
+ if (!Number.isFinite(file.size) || file.size <= 0 || file.size > maxBitmapSourceBytes) return false;
79
+ if (!Number.isFinite(info.width) || !Number.isFinite(info.height) || info.width <= 0 || info.height <= 0) return false;
80
+ if (info.width > maxBitmapDimension || info.height > maxBitmapDimension) return false;
81
+ var pixels = info.width * info.height;
82
+ if (!Number.isFinite(pixels) || pixels > maxBitmapPixels) return false;
83
+ return pixels * 4 <= maxBitmapDecodedBytes;
84
+ }`
85
+
86
+ const BITMAP_NORMALIZE_BASE = String.raw` function normalizeFile(file) {
87
+ return headType(file).then(function(info) {
88
+ var detected = info && info.type;
89
+ if (detected && supported[detected]) {
90
+ return mediaType(file && file.type) === detected ? file : retypeFile(file, detected);
91
+ }
92
+ if (detected === 'image/bmp' && bitmapDecodeAllowed(file, info)) return bitmapToPng(file);
93
+ return file;
94
+ }, function(){ return file; });
95
+ }`
96
+
97
+ const BITMAP_NORMALIZE_HARDENED = String.raw` function normalizeFile(file, decodeBudget) {
98
+ return headType(file).then(function(info) {
99
+ var detected = info && info.type;
100
+ if (detected && supported[detected]) {
101
+ return mediaType(file && file.type) === detected ? file : retypeFile(file, detected);
102
+ }
103
+ if (detected === 'image/bmp' && bitmapDecodeAllowed(file, info)) {
104
+ var decodedBytes = info.width * info.height * 4;
105
+ if (decodeBudget && decodedBytes > decodeBudget.remaining) return file;
106
+ if (decodeBudget) decodeBudget.remaining -= decodedBytes;
107
+ return bitmapToPng(file);
108
+ }
109
+ return file;
110
+ }, function(){ return file; });
111
+ }`
112
+
113
+ const BITMAP_BATCH_BASE = String.raw` Promise.all(files.map(function(file){
114
+ return needsInspection(file)
115
+ ? normalizeFile(file).catch(function(){ return file; })
116
+ : Promise.resolve(file);
117
+ })).then(function(normalized) {
118
+ return dedupeBatch(normalized).then(function(deduped) {
119
+ dispatchReplay(target, replayEvent(deduped, textEntries), fallback);
120
+ });
121
+ }, function() {
122
+ dispatchReplay(target, fallback, null);
123
+ });`
124
+
125
+ const BITMAP_BATCH_HARDENED = String.raw` // Decode BMPs serially under one paste-scoped RGBA budget. This keeps peak
126
+ // browser memory bounded before the Host attachment governor gets a turn.
127
+ var decodeBudget = { remaining: maxBitmapBatchDecodedBytes };
128
+ var normalized = [];
129
+ var normalizeChain = Promise.resolve();
130
+ files.forEach(function(file){
131
+ normalizeChain = normalizeChain.then(function(){
132
+ if (!needsInspection(file)) {
133
+ normalized.push(file);
134
+ return undefined;
135
+ }
136
+ return normalizeFile(file, decodeBudget).then(function(value){
137
+ normalized.push(value);
138
+ }, function(){
139
+ normalized.push(file);
140
+ });
141
+ });
142
+ });
143
+ normalizeChain.then(function() {
144
+ return dedupeBatch(normalized).then(function(deduped) {
145
+ dispatchReplay(target, replayEvent(deduped, textEntries), fallback);
146
+ });
147
+ }, function() {
148
+ dispatchReplay(target, fallback, null);
149
+ });`
150
+
151
+ function replaceAllRequired(source, anchor, replacement, label) {
152
+ const parts = source.split(anchor)
153
+ const count = parts.length - 1
154
+ if (count === 0) throw new Error(`${label} transform anchor missing`)
155
+ return parts.join(replacement)
156
+ }
157
+
55
158
  export function transformVisionModeToggleIcons(source = BASE_CLIENT_PRESENTATION_PRELUDE) {
56
159
  const input = String(source)
57
160
  if (!input.includes(VISION_TOGGLE_TEXT_GLYPH)) {
@@ -60,7 +163,24 @@ export function transformVisionModeToggleIcons(source = BASE_CLIENT_PRESENTATION
60
163
  return input.replace(VISION_TOGGLE_TEXT_GLYPH, VISION_TOGGLE_FIXED_SVG)
61
164
  }
62
165
 
63
- export const CLIENT_PRESENTATION_PRELUDE = transformVisionModeToggleIcons()
166
+ export function transformBitmapDecodeBudget(source = BASE_CLIENT_PRESENTATION_PRELUDE) {
167
+ let output = String(source)
168
+ // The legacy prelude intentionally contains more than one clipboard owner.
169
+ // Harden every exact owner, not merely the first string match, so one stale
170
+ // path cannot retain unbounded parallel bitmap decoding.
171
+ output = replaceAllRequired(output, BITMAP_LIMITS_BASE, BITMAP_LIMITS_HARDENED, 'bitmap limits')
172
+ output = replaceAllRequired(
173
+ output,
174
+ BITMAP_DECODE_ALLOWED_BASE,
175
+ BITMAP_DECODE_ALLOWED_HARDENED,
176
+ 'bitmap decoded-byte admission',
177
+ )
178
+ output = replaceAllRequired(output, BITMAP_NORMALIZE_BASE, BITMAP_NORMALIZE_HARDENED, 'bitmap normalization')
179
+ output = replaceAllRequired(output, BITMAP_BATCH_BASE, BITMAP_BATCH_HARDENED, 'bitmap batch serialization')
180
+ return output
181
+ }
182
+
183
+ export const CLIENT_PRESENTATION_PRELUDE = transformBitmapDecodeBudget(transformVisionModeToggleIcons())
64
184
 
65
185
  export {
66
186
  resolveVisionModePair,
@@ -9,6 +9,7 @@ import {
9
9
  } from './dsh-host-capabilities.js'
10
10
  import {
11
11
  DSH_SUPPORT_WINDOW,
12
+ DSH_VERIFICATION_EVIDENCE,
12
13
  formatDshSupportWindowLines,
13
14
  supportWindowUpgradeAdvice,
14
15
  } from './dsh-support-window.js'
@@ -57,7 +58,7 @@ export async function probeDoctorHostCapabilities({ baseUrl, fetchImpl = globalT
57
58
  if (!response.ok) {
58
59
  return {
59
60
  ok: false,
60
- source: 'runtime-route-unavailable',
61
+ source: response.status === 401 ? 'runtime-auth-required' : 'runtime-route-unavailable',
61
62
  status: response.status,
62
63
  capabilities: unknownSnapshot(),
63
64
  }
@@ -143,6 +144,7 @@ export async function run(argv = process.argv.slice(2), io = console, env = proc
143
144
  report.hostCapabilities = probe.capabilities
144
145
  report.hostCapabilitiesSource = probe.source
145
146
  report.hostSupportWindow = DSH_SUPPORT_WINDOW
147
+ report.hostVerificationEvidence = DSH_VERIFICATION_EVIDENCE
146
148
  report.hostSupportAdvice = supportWindowUpgradeAdvice(probe.capabilities)
147
149
  io.log(JSON.stringify(report, null, 2))
148
150
  return code
package/lib/doctor-cli.js CHANGED
@@ -241,7 +241,10 @@ export async function run(argv = process.argv.slice(2), io = console, env = proc
241
241
 
242
242
  if (runtime) {
243
243
  if (!runtime.reachable) io.log(`– Runtime probe: DSH not reachable at ${runtime.baseUrl}; offline checks still completed.`)
244
- else {
244
+ else if (runtime.authenticationRequired) {
245
+ io.log('? Runtime probe: DSH Web is reachable but browser authentication is required; plugin route health remains unknown.')
246
+ if (options.profile) io.log(`? runtime profile ownership unknown; authenticated route health is not attributed to ${options.profile}`)
247
+ } else {
245
248
  for (const route of runtime.routes) {
246
249
  io.log(`${route.ok ? '✓' : '✗'} runtime ${route.route} — ${route.ok ? 'route registered' : `status ${route.status ?? 'unreachable'}, route not confirmed`}`)
247
250
  }
@@ -92,14 +92,17 @@ export async function probeRuntime({
92
92
  }))
93
93
 
94
94
  const reachable = routes.some((item) => !item.unreachable)
95
+ const authenticationRequired = reachable && routes.length > 0 && routes.every((item) => item.status === 401)
95
96
  const routeOk = !reachable || routes.every((item) => item.ok)
96
- let ownership = { verified: false, requestedProfile, reason: requestedProfile ? 'profile-unknown' : 'not-requested' }
97
+ let ownership = authenticationRequired
98
+ ? { verified: false, requestedProfile, reason: 'runtime-auth-required' }
99
+ : { verified: false, requestedProfile, reason: requestedProfile ? 'profile-unknown' : 'not-requested' }
97
100
 
98
101
  // A DELETE contract proves route registration but not which DSH profile owns
99
102
  // the process. Reuse the existing GET-only log metadata route as a read-only
100
103
  // home identity proof. It never opens the log folder (that is POST only).
101
104
  // Only a unique applicable profile in that same DSH_HOME may be attributed.
102
- if (reachable && routeOk && requestedProfile && typeof dshHome === 'string') {
105
+ if (reachable && routeOk && !authenticationRequired && requestedProfile && typeof dshHome === 'string') {
103
106
  try {
104
107
  const response = await fetchImpl(new URL('/_dsh/vision-router/logs', base).href, {
105
108
  method: 'GET',
@@ -131,8 +134,9 @@ export async function probeRuntime({
131
134
  reachable,
132
135
  routes,
133
136
  ownership,
137
+ authenticationRequired,
134
138
  routeOk,
135
- ok: !reachable || (routeOk && (!requestedProfile || ownership.verified)),
139
+ ok: !reachable || authenticationRequired || (routeOk && (!requestedProfile || ownership.verified)),
136
140
  }
137
141
  }
138
142
 
@@ -302,6 +306,7 @@ function safeRuntime(runtime) {
302
306
  reachable: runtime.reachable,
303
307
  ok: runtime.ok,
304
308
  routeOk: runtime.routeOk,
309
+ authenticationRequired: runtime.authenticationRequired === true,
305
310
  ownership: runtime.ownership ? {
306
311
  requestedProfile: runtime.ownership.requestedProfile,
307
312
  verified: runtime.ownership.verified,
@@ -1,9 +1,14 @@
1
1
  export const DSH_SUPPORT_WINDOW = Object.freeze({
2
2
  dvrTrain: '2.1.x',
3
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',
4
+ currentStable: '0.1.2-rc.1',
5
+ })
6
+
7
+ export const DSH_VERIFICATION_EVIDENCE = Object.freeze({
8
+ exactStable: '0.1.2-rc.1',
9
+ exactPreview: '0.1.3-alpha.2',
10
+ stableCanaryDistTag: 'latest',
11
+ previewCanaryDistTag: 'alpha',
7
12
  })
8
13
 
9
14
  export function supportWindowUpgradeAdvice(capabilities = {}) {
@@ -38,11 +43,14 @@ export function formatDshSupportWindowLines(capabilities = {}) {
38
43
  return Object.freeze([
39
44
  'DSH Host support window:',
40
45
  ` 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}`,
46
+ ` minimum supported Host: ${DSH_SUPPORT_WINDOW.minimum}`,
47
+ ` current stable Host: ${DSH_SUPPORT_WINDOW.currentStable}`,
45
48
  ` support floor: DVR ${DSH_SUPPORT_WINDOW.dvrTrain} -> DSH ${DSH_SUPPORT_WINDOW.minimum}`,
49
+ 'DSH compatibility verification evidence:',
50
+ ` exact stable: ${DSH_VERIFICATION_EVIDENCE.exactStable}`,
51
+ ` exact preview (not a support claim): ${DSH_VERIFICATION_EVIDENCE.exactPreview}`,
52
+ ` scheduled stable canary: npm dist-tag ${DSH_VERIFICATION_EVIDENCE.stableCanaryDistTag}`,
53
+ ` scheduled preview canary: npm dist-tag ${DSH_VERIFICATION_EVIDENCE.previewCanaryDistTag}`,
46
54
  ` upgrade advice: ${advice.level} (${advice.code}) — ${advice.message}`,
47
55
  ])
48
56
  }
@@ -7,7 +7,7 @@ import { MODEL_RESPONSE_MAX_BYTES, readResponseJsonBounded } from './http-body-l
7
7
  import { stripTrailingSlashes } from './string-normalization.js'
8
8
 
9
9
  export const LIVE_MODELS_PATH = '/_dsh/vision-router/live-models'
10
- export const LIVE_MODEL_CACHE_VERSION = 1
10
+ export const LIVE_MODEL_CACHE_VERSION = 2
11
11
  export const DEFAULT_LIVE_MODEL_FRESH_MS = 15 * 60 * 1000
12
12
  export const DEFAULT_LIVE_MODEL_STALE_MS = 24 * 60 * 60 * 1000
13
13
  export const DEFAULT_LIVE_MODEL_TIMEOUT_MS = 6_000
@@ -66,23 +66,16 @@ export function liveModelCachePath(dshHome = resolveDshHome()) {
66
66
  return path.join(dshHome, 'cache', 'vision-router', 'live-models.json')
67
67
  }
68
68
 
69
- export function routeFingerprint({ provider, baseURL, api, credentialFingerprint }) {
69
+ export function routeFingerprint({ provider, baseURL, api }) {
70
70
  return createHash('sha256')
71
71
  .update(JSON.stringify({
72
72
  provider: String(provider ?? ''),
73
73
  baseURL: String(baseURL ?? ''),
74
74
  api: String(api ?? ''),
75
- credentialFingerprint: String(credentialFingerprint ?? ''),
76
75
  }))
77
76
  .digest('hex')
78
77
  }
79
78
 
80
- function credentialFingerprint(value) {
81
- const text = typeof value === 'string' ? value : ''
82
- if (text === '') return 'none'
83
- return createHash('sha256').update(text).digest('hex').slice(0, 24)
84
- }
85
-
86
79
  function listingURL(baseURL) {
87
80
  return `${stripTrailingSlashes(baseURL)}/models`
88
81
  }
@@ -308,7 +301,7 @@ async function providerPlan(ctx, provider) {
308
301
  // DSH llm-pi-ai treats a named apiKeyEnv as mandatory. Sending `/models`
309
302
  // without that key is both misleading (the user sees a spurious 401) and can
310
303
  // poison route fingerprints/cache evidence. Defer instead; the browser's
311
- // refresh polling and credentials/updated invalidation will retry once the
304
+ // refresh polling and credential-reference invalidation will retry once the
312
305
  // credential seam is ready or the user stores the key.
313
306
  if (credential.required && credential.value === undefined) {
314
307
  return {
@@ -328,7 +321,6 @@ async function providerPlan(ctx, provider) {
328
321
  provider,
329
322
  baseURL: transport.baseURL,
330
323
  api: transport.api,
331
- credentialFingerprint: credentialFingerprint(apiKey),
332
324
  }),
333
325
  }
334
326
  }
@@ -550,13 +542,27 @@ export function installLiveModelDiscovery(ctx, options = {}) {
550
542
  }
551
543
  scheduleStartup()
552
544
 
553
- const onInvalidate = () => manager.invalidate()
545
+ let credentialInvalidationQueued = false
546
+ let lifecycleDisposed = false
547
+ const onInvalidate = () => {
548
+ if (!lifecycleDisposed) manager.invalidate()
549
+ }
550
+ const onCredentialInvalidate = () => {
551
+ if (lifecycleDisposed || credentialInvalidationQueued) return
552
+ credentialInvalidationQueued = true
553
+ manager.invalidate()
554
+ Promise.resolve().then(() => { credentialInvalidationQueued = false })
555
+ }
554
556
  const disposers = []
555
557
  try {
556
- for (const event of ['settings/document-updated', 'credentials/updated', 'llm/adapters-updated']) {
558
+ for (const event of ['settings/document-updated', 'llm/adapters-updated']) {
557
559
  const dispose = ctx?.on?.(event, onInvalidate)
558
560
  if (typeof dispose === 'function') disposers.push(dispose)
559
561
  }
562
+ for (const event of ['credentials/reference-updated', 'credentials/updated']) {
563
+ const dispose = ctx?.on?.(event, onCredentialInvalidate)
564
+ if (typeof dispose === 'function') disposers.push(dispose)
565
+ }
560
566
  } catch {
561
567
  // Event forwarding is an optimization; the request path still refreshes.
562
568
  }
@@ -584,6 +590,7 @@ export function installLiveModelDiscovery(ctx, options = {}) {
584
590
 
585
591
  try {
586
592
  ctx?.effect?.(() => () => {
593
+ lifecycleDisposed = true
587
594
  if (startupTimer !== undefined) clearTimeout(startupTimer)
588
595
  for (const dispose of disposers) {
589
596
  try { dispose() } catch { /* best effort */ }
@@ -1,3 +1,6 @@
1
+ import { currentVisionSessionAffinityId } from './session-affinity-runtime.js'
2
+ import { isOfficialOpenCodeGoUrl, openCodeSessionAffinityHeaderForUrl } from './session-affinity.js'
3
+
1
4
  /**
2
5
  * DSH 0.1.1-rc.1 lets a pi-ai route/model declare OpenAI-completions wire
3
6
  * compatibility such as `compat.maxTokensField`, and route-owned request
@@ -10,7 +13,12 @@
10
13
  * wire fingerprint (`stream:false`, image_url content, /chat/completions), so a
11
14
  * narrowly scoped fetch wrapper can recover the exact resolved pi-ai
12
15
  * route/model by URL + model id and apply only the missing wire facts.
13
- * Normal DSH/pi-ai traffic is streaming and therefore never matches.
16
+ * Normal DSH/pi-ai traffic is streaming and therefore never matches that
17
+ * legacy rewrite. A second, orthogonal rule lives in the same fetch boundary:
18
+ * while a Vision Router-owned AsyncLocalStorage scope is active, requests to
19
+ * the official OpenCode Go endpoint receive x-opencode-session if upstream has
20
+ * not already supplied it. That rule is endpoint-scoped and self-retires when
21
+ * DSH/pi-ai learns the native header.
14
22
  */
15
23
 
16
24
  function isObject(value) {
@@ -38,6 +46,23 @@ function requestUrl(input) {
38
46
  return undefined
39
47
  }
40
48
 
49
+ function requestMethod(input, init) {
50
+ if (typeof init?.method === 'string' && init.method !== '') return init.method.toUpperCase()
51
+ if (typeof Request !== 'undefined' && input instanceof Request) return input.method.toUpperCase()
52
+ return 'GET'
53
+ }
54
+
55
+ function isOpenCodeGoInferenceRequest(input, init) {
56
+ const url = requestUrl(input)
57
+ if (url === undefined || !isOfficialOpenCodeGoUrl(url) || requestMethod(input, init) !== 'POST') return false
58
+ try {
59
+ const pathname = new URL(url).pathname.replace(/\/$/, '')
60
+ return /\/(?:chat\/completions|responses|messages)$/i.test(pathname)
61
+ } catch {
62
+ return false
63
+ }
64
+ }
65
+
41
66
  function parseJsonBody(init) {
42
67
  if (!init || typeof init.body !== 'string') return undefined
43
68
  try {
@@ -201,6 +226,28 @@ function mergeHeaders(profileHeaders, requestHeaders) {
201
226
  return { ...profileHeaders, ...(requestHeaders ?? {}) }
202
227
  }
203
228
 
229
+ function combinedRequestHeaders(input, init) {
230
+ const merged = new Headers(
231
+ typeof Request !== 'undefined' && input instanceof Request ? input.headers : undefined,
232
+ )
233
+ new Headers(init?.headers ?? {}).forEach((value, name) => merged.set(name, value))
234
+ return merged
235
+ }
236
+
237
+ /**
238
+ * Pure scoped-header projection. Existing/native x-opencode-session always
239
+ * wins, making the compatibility layer a no-op as soon as upstream supports it.
240
+ */
241
+ export function applyScopedOpenCodeSessionAffinity(input, init, affinityId) {
242
+ const url = requestUrl(input)
243
+ if (url === undefined || !isOfficialOpenCodeGoUrl(url)) return init
244
+ const headers = combinedRequestHeaders(input, init)
245
+ if (headers.has('x-opencode-session')) return init
246
+ const wire = openCodeSessionAffinityHeaderForUrl(url, affinityId)
247
+ headers.set('x-opencode-session', wire)
248
+ return { ...init, headers }
249
+ }
250
+
204
251
  /** Pure request rewrite used by the installed fetch boundary and tests. */
205
252
  export function applyPiAiBridgeWireFacts(init, body, facts) {
206
253
  if (!facts || !isObject(body)) return init
@@ -230,8 +277,9 @@ export function applyPiAiBridgeWireFacts(init, body, facts) {
230
277
  }
231
278
 
232
279
  /**
233
- * Install one process fetch wrapper. It is inert for ordinary streaming DSH
234
- * calls and for Vision Router's unrelated HTTP providers. Cleanup disables the
280
+ * Install one process fetch wrapper. Ordinary DSH traffic remains inert; only
281
+ * the legacy non-streaming image bridge or an explicit Vision Router affinity
282
+ * scope can modify a request. Cleanup disables the
235
283
  * wrapper even if another later patch sits above it in the fetch chain.
236
284
  */
237
285
  export function installPiAiBridgeWireCompat(ctx, logger) {
@@ -240,8 +288,19 @@ export function installPiAiBridgeWireCompat(ctx, logger) {
240
288
  let active = true
241
289
  const wrapped = async (input, init) => {
242
290
  if (!active) return Reflect.apply(original, globalThis, [input, init])
291
+ let patchedInit = init
292
+
293
+ // This is not advisory: a Vision Router-owned request to OpenCode Go must
294
+ // either carry the exact safe affinity id or fail before the network. The
295
+ // check is scoped by AsyncLocalStorage, so unrelated Host/plugin traffic is
296
+ // untouched even though this wrapper is process-visible.
297
+ const scopedAffinity = currentVisionSessionAffinityId()
298
+ if (scopedAffinity !== undefined && isOpenCodeGoInferenceRequest(input, patchedInit)) {
299
+ patchedInit = applyScopedOpenCodeSessionAffinity(input, patchedInit, scopedAffinity)
300
+ }
301
+
243
302
  try {
244
- const body = parseJsonBody(init)
303
+ const body = parseJsonBody(patchedInit)
245
304
  const url = requestUrl(input)
246
305
  const bridgeShape =
247
306
  body?.stream === false &&
@@ -252,8 +311,9 @@ export function installPiAiBridgeWireCompat(ctx, logger) {
252
311
  if (bridgeShape) {
253
312
  const facts = resolvePiAiBridgeWireFacts(ctx, url, body.model)
254
313
  if (facts !== undefined) {
255
- const patched = applyPiAiBridgeWireFacts(init, body, facts)
256
- if (patched !== init) {
314
+ const bridged = applyPiAiBridgeWireFacts(patchedInit, body, facts)
315
+ if (bridged !== patchedInit) {
316
+ patchedInit = bridged
257
317
  try {
258
318
  logger?.info?.(
259
319
  'vision-router: applied pi-ai bridge wire compat [%s/%s] maxTokensField=%s headers=%d',
@@ -265,15 +325,14 @@ export function installPiAiBridgeWireCompat(ctx, logger) {
265
325
  } catch {
266
326
  // Diagnostics never affect the request.
267
327
  }
268
- return Reflect.apply(original, globalThis, [input, patched])
269
328
  }
270
329
  }
271
330
  }
272
331
  } catch {
273
- // Compatibility lookup is advisory. Any unexpected shape keeps the
274
- // exact pre-existing request rather than turning a bridge into a crash.
332
+ // Legacy bridge lookup is advisory. Any unexpected shape keeps the
333
+ // exact request after the mandatory scoped affinity projection above.
275
334
  }
276
- return Reflect.apply(original, globalThis, [input, init])
335
+ return Reflect.apply(original, globalThis, [input, patchedInit])
277
336
  }
278
337
  globalThis.fetch = wrapped
279
338
  const cleanup = () => {
@@ -0,0 +1,104 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import { rawSessionIdentity } from './session-affinity.js'
3
+
4
+ const affinityRuntime = new AsyncLocalStorage()
5
+
6
+ export function currentVisionSessionAffinityId() {
7
+ const state = affinityRuntime.getStore()
8
+ return state?.active === true ? state.affinityId : undefined
9
+ }
10
+
11
+ export function runWithVisionSessionAffinity(affinityId, callback) {
12
+ const raw = rawSessionIdentity(affinityId)
13
+ if (raw === undefined) return callback()
14
+ const state = { affinityId: raw, active: true }
15
+ let result
16
+ try {
17
+ result = affinityRuntime.run(state, callback)
18
+ } catch (error) {
19
+ state.active = false
20
+ throw error
21
+ }
22
+ if (result && typeof result.then === 'function') {
23
+ return Promise.resolve(result).finally(() => { state.active = false })
24
+ }
25
+ state.active = false
26
+ return result
27
+ }
28
+
29
+ /**
30
+ * Keep AsyncLocalStorage active across lazy AsyncIterable creation and every
31
+ * iterator operation. Adapter/network work often begins on next(), not when
32
+ * ctx.llm.stream() returns the iterable. Promise-returning test/compat streams
33
+ * are supported too because some older seams resolve the iterable lazily.
34
+ * One mutable state object is retired at stream completion so async work that
35
+ * outlives the model request cannot keep projecting the conversation id.
36
+ */
37
+ export function streamWithVisionSessionAffinity(affinityId, streamFactory) {
38
+ const raw = rawSessionIdentity(affinityId)
39
+ if (raw === undefined) return streamFactory()
40
+ return {
41
+ [Symbol.asyncIterator]() {
42
+ const state = { affinityId: raw, active: true }
43
+ let iteratorPromise
44
+ const retire = () => { state.active = false }
45
+ const ensure = () => {
46
+ if (iteratorPromise === undefined) {
47
+ iteratorPromise = affinityRuntime.run(state, async () => {
48
+ const stream = await streamFactory()
49
+ if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') {
50
+ throw new TypeError('scoped vision stream is not async iterable')
51
+ }
52
+ return stream[Symbol.asyncIterator]()
53
+ })
54
+ }
55
+ return iteratorPromise
56
+ }
57
+ return {
58
+ next(value) {
59
+ return affinityRuntime.run(state, async () => {
60
+ try {
61
+ const result = await (await ensure()).next(value)
62
+ if (result?.done === true) retire()
63
+ return result
64
+ } catch (error) {
65
+ retire()
66
+ throw error
67
+ }
68
+ })
69
+ },
70
+ return(value) {
71
+ if (iteratorPromise === undefined) {
72
+ retire()
73
+ return Promise.resolve({ done: true, value })
74
+ }
75
+ return affinityRuntime.run(state, async () => {
76
+ try {
77
+ const active = await iteratorPromise
78
+ return typeof active.return === 'function'
79
+ ? await active.return(value)
80
+ : { done: true, value }
81
+ } finally {
82
+ retire()
83
+ }
84
+ })
85
+ },
86
+ throw(error) {
87
+ if (iteratorPromise === undefined) {
88
+ retire()
89
+ return Promise.reject(error)
90
+ }
91
+ return affinityRuntime.run(state, async () => {
92
+ try {
93
+ const active = await iteratorPromise
94
+ if (typeof active.throw === 'function') return await active.throw(error)
95
+ throw error
96
+ } finally {
97
+ retire()
98
+ }
99
+ })
100
+ },
101
+ }
102
+ },
103
+ }
104
+ }