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.
@@ -0,0 +1,93 @@
1
+ const MAX_SESSION_AFFINITY_ID_LENGTH = 512
2
+
3
+ /** Preserve DSH's in-process session identity exactly; no wire rules belong here. */
4
+ export function rawSessionIdentity(value) {
5
+ if (value === undefined || value === null) return undefined
6
+ try {
7
+ return String(value)
8
+ } catch {
9
+ return undefined
10
+ }
11
+ }
12
+
13
+ /** Read the live DSH session id across released/alpha session shapes without rewriting it. */
14
+ export function sessionIdentityOf(session) {
15
+ if (!session) return undefined
16
+ try {
17
+ const direct = rawSessionIdentity(session.id)
18
+ if (direct !== undefined) return direct
19
+ } catch {
20
+ /* continue to persisted/header shapes */
21
+ }
22
+ try {
23
+ const header = rawSessionIdentity(session.header?.id)
24
+ if (header !== undefined) return header
25
+ } catch {
26
+ /* continue to requestHeader() */
27
+ }
28
+ try {
29
+ if (typeof session.requestHeader === 'function') {
30
+ return rawSessionIdentity(session.requestHeader()?.id)
31
+ }
32
+ } catch {
33
+ /* no usable session identity */
34
+ }
35
+ return undefined
36
+ }
37
+
38
+ /**
39
+ * Admit an opaque identity to an HTTP header without changing it. Fetch/undici
40
+ * requires ByteString-compatible header values; use visible ASCII so behavior
41
+ * is identical across Node 22/24 and HTTP implementations. Leading/trailing
42
+ * whitespace is rejected because Headers would normalize it, violating the
43
+ * requirement to preserve the DSH identity unchanged.
44
+ */
45
+ export function wireSessionAffinityId(value) {
46
+ const text = rawSessionIdentity(value)
47
+ if (text === undefined || text === '' || text.length > MAX_SESSION_AFFINITY_ID_LENGTH) return undefined
48
+ if (text.trim() !== text) return undefined
49
+ if (!/^[\x21-\x7e](?:[\x20-\x7e]*[\x21-\x7e])?$/.test(text)) return undefined
50
+ return text
51
+ }
52
+
53
+ export function isOfficialOpenCodeGoUrl(value) {
54
+ try {
55
+ const url = new URL(String(value))
56
+ return (
57
+ url.protocol === 'https:' &&
58
+ url.hostname.toLowerCase() === 'opencode.ai' &&
59
+ /^\/zen\/go(?:\/|$)/i.test(url.pathname)
60
+ )
61
+ } catch {
62
+ return false
63
+ }
64
+ }
65
+
66
+ export function isOpenCodeGoProvider(provider) {
67
+ return isOfficialOpenCodeGoUrl(provider?.baseURL ?? provider?.baseUrl ?? '')
68
+ }
69
+
70
+ function invalidAffinityError(sessionId) {
71
+ const raw = rawSessionIdentity(sessionId)
72
+ const error = new Error(
73
+ raw === undefined || raw === ''
74
+ ? 'OpenCode Go requires a stable session identity; refusing a direct request without one'
75
+ : 'OpenCode Go session identity is not safe for an HTTP header; refusing to rewrite or truncate it',
76
+ )
77
+ error.code = raw === undefined || raw === '' ? 'OPENCODE_SESSION_REQUIRED' : 'OPENCODE_SESSION_INVALID'
78
+ return error
79
+ }
80
+
81
+ export function directSessionAffinityHeaders(provider, sessionId) {
82
+ if (!isOpenCodeGoProvider(provider)) return {}
83
+ const wire = wireSessionAffinityId(sessionId)
84
+ if (wire === undefined) throw invalidAffinityError(sessionId)
85
+ return { 'x-opencode-session': wire }
86
+ }
87
+
88
+ export function openCodeSessionAffinityHeaderForUrl(url, sessionId) {
89
+ if (!isOfficialOpenCodeGoUrl(url)) return undefined
90
+ const wire = wireSessionAffinityId(sessionId)
91
+ if (wire === undefined) throw invalidAffinityError(sessionId)
92
+ return wire
93
+ }
@@ -4,7 +4,6 @@ import { tmpdir } from 'node:os'
4
4
  import { createRequire, syncBuiltinESMExports } from 'node:module'
5
5
  import { promisify } from 'node:util'
6
6
 
7
- import { rewriteWindowsScreenshotExecArgs } from './windows-screenshot-dpi-compat.js'
8
7
 
9
8
  const require = createRequire(import.meta.url)
10
9
  const childProcess = require('node:child_process')
@@ -127,38 +126,12 @@ export function createTesseractPromisifyCompat(execFileImpl, originalCustom, opt
127
126
  }
128
127
 
129
128
  /**
130
- * Compose every Vision Router execFile compatibility transform behind ONE
131
- * promisify.custom function. The screenshot transform changes only the exact
132
- * legacy Windows VirtualScreen/CopyFromScreen invocation emitted by core;
133
- * every other child-process call remains byte-for-byte delegated.
129
+ * Historical public helper name retained for compatibility. Screenshot capture
130
+ * no longer participates in this process-wide seam (#409); only the Tesseract
131
+ * stdin compatibility behavior remains.
134
132
  */
135
133
  export function createVisionRouterExecFilePromisifyCompat(execFileImpl, originalCustom, options = {}) {
136
- const tesseractCompat = createTesseractPromisifyCompat(execFileImpl, originalCustom, options)
137
- let active = true
138
-
139
- async function visionRouterExecFilePromisifyCompat(file, args, execOptions) {
140
- if (!active) return tesseractCompat(file, args, execOptions)
141
- const rewrittenArgs = rewriteWindowsScreenshotExecArgs(file, args, {
142
- platform: options.platform ?? (typeof process !== 'undefined' ? process.platform : ''),
143
- })
144
- return tesseractCompat(file, rewrittenArgs, execOptions)
145
- }
146
-
147
- Object.defineProperty(visionRouterExecFilePromisifyCompat, 'deactivate', {
148
- configurable: false,
149
- enumerable: false,
150
- value() {
151
- active = false
152
- tesseractCompat.deactivate?.()
153
- },
154
- })
155
- Object.defineProperty(visionRouterExecFilePromisifyCompat, 'active', {
156
- configurable: false,
157
- enumerable: false,
158
- get() { return active },
159
- })
160
-
161
- return visionRouterExecFilePromisifyCompat
134
+ return createTesseractPromisifyCompat(execFileImpl, originalCustom, options)
162
135
  }
163
136
 
164
137
  function canReplaceCustomPromisify(execFileImpl) {
@@ -207,7 +180,7 @@ function syncIfBuiltin(moduleObject) {
207
180
  function warnInstallDegraded(ctx, reason) {
208
181
  try {
209
182
  ctx?.logger?.warn?.(
210
- 'vision-router: execFile compatibility could not be installed (%s); continuing without local OCR / Windows DPI capture shims',
183
+ 'vision-router: execFile compatibility could not be installed (%s); continuing without local OCR stdin compatibility',
211
184
  reason,
212
185
  )
213
186
  } catch {
@@ -216,10 +189,10 @@ function warnInstallDegraded(ctx, reason) {
216
189
  }
217
190
 
218
191
  /**
219
- * Install the single process-wide execFile compatibility boundary used by
220
- * Vision Router. The public runtime still calls the historical
221
- * installTesseractExecFileCompat alias; keeping one owner prevents cleanup
222
- * order bugs between independently stacked wrappers.
192
+ * Install the process-wide execFile compatibility boundary used only by the
193
+ * historical local Tesseract stdin path. Windows desktop capture is direct and
194
+ * intentionally independent of this lifecycle. The public runtime still calls
195
+ * the historical installTesseractExecFileCompat alias.
223
196
  */
224
197
  export function installVisionRouterExecFileCompat(ctx, options = {}) {
225
198
  const moduleObject = options.childProcessModule ?? childProcess
@@ -241,6 +241,7 @@ function directStream(ctx, core, plan, call, logger, source) {
241
241
  {
242
242
  maxTokens: call.maxTokens ?? 4096,
243
243
  signal: call.signal,
244
+ sessionId: call.sessionId,
244
245
  resolveCredential: async (ref) => ref === keyRef ? apiKey : undefined,
245
246
  },
246
247
  )
@@ -7,15 +7,11 @@ import {
7
7
  } from './vision-capability-benchmark-service.js'
8
8
  import { withHardDeadline } from './vision-capability-benchmark-hardening.js'
9
9
  import { resolveVisionRoutingAuthority } from './vision-routing-authority.js'
10
- import { resolveVisionCredential } from './vision-capability-identity.js'
11
10
  import { redactDiagnosticText } from './diagnostic-redaction.js'
12
11
  import { injectVisionExactCheckClient } from './vision-exact-check-client.js'
13
12
  import { createImageInputVerdictStore } from './vision-image-input-verdict.js'
14
13
  import { createBackgroundBenchmarkStopStore } from './vision-background-stop-store.js'
15
- import {
16
- backgroundFailurePolicy,
17
- credentialFingerprintChanged,
18
- } from './vision-background-failure-policy.js'
14
+ import { backgroundFailurePolicy } from './vision-background-failure-policy.js'
19
15
  import { isLocalUiRequest } from './web-capability-boundary.js'
20
16
 
21
17
  const FOREGROUND_VISION_TOOLS = new Set([
@@ -43,6 +39,11 @@ const MODEL_WIDE_NON_RETRYABLE_BACKGROUND_FAILURES = new Set([
43
39
  'protocol',
44
40
  'unavailable',
45
41
  ])
42
+ const TRANSPORT_WIDE_BACKGROUND_FAILURES = new Set([
43
+ 'auth',
44
+ 'protocol',
45
+ 'rate-limit',
46
+ ])
46
47
  const PERMANENT_MODEL_UNAVAILABLE_CODES = new Set([
47
48
  'MODEL_NOT_FOUND',
48
49
  'MODEL_DOES_NOT_EXIST',
@@ -145,6 +146,21 @@ function backoffKey(candidate, axis) {
145
146
  return `${candidate?.key ?? ''}\u0000${candidate?.endpointFingerprint ?? ''}\u0000${axis}`
146
147
  }
147
148
 
149
+ function backgroundTransportScope(candidate) {
150
+ const provider = typeof candidate?.provider === 'string' ? candidate.provider.trim() : ''
151
+ const endpoint = normalizedEndpoint(candidate?.endpoint)
152
+ if (!provider || !endpoint) return undefined
153
+ const api = typeof candidate?.endpointConfig?.api === 'string' ? candidate.endpointConfig.api.trim().toLowerCase() : ''
154
+ const credentialRef = typeof candidate?.endpointCredentialRef === 'string' ? candidate.endpointCredentialRef.trim() : ''
155
+ return JSON.stringify([provider, endpoint, api, credentialRef])
156
+ }
157
+
158
+ function transportFailureScope(candidate, errorClass) {
159
+ return TRANSPORT_WIDE_BACKGROUND_FAILURES.has(errorClass)
160
+ ? backgroundTransportScope(candidate)
161
+ : undefined
162
+ }
163
+
148
164
  function classifyUnavailable(value) {
149
165
  const status = Number(value?.status)
150
166
  const code = String(value?.code ?? '').toUpperCase()
@@ -192,26 +208,14 @@ function publicExcludedEntries(excluded) {
192
208
  .map((entry) => ({ key: entry.key, reason: entry.reason }))
193
209
  }
194
210
 
195
- async function candidateCredentialFingerprint(ctx, candidate) {
196
- const ref = typeof candidate?.endpointCredentialRef === 'string'
197
- ? candidate.endpointCredentialRef.trim()
198
- : ''
199
- if (!ref) return undefined
200
- try {
201
- return (await resolveVisionCredential(ctx, ref)).fingerprint
202
- } catch {
203
- return 'unresolved'
204
- }
205
- }
206
211
 
207
- async function pruneBackoff(backoff, candidates, ctx, at) {
212
+ async function pruneBackoff(backoff, candidates, _ctx, at) {
208
213
  const valid = new Set()
209
214
  const byKey = new Map()
210
215
  for (const candidate of candidates) {
211
216
  byKey.set(candidate.key, candidate)
212
217
  for (const axis of orderedAxes()) valid.add(backoffKey(candidate, axis))
213
218
  }
214
- const credentialCache = new Map()
215
219
  for (const [key, entry] of backoff.entries()) {
216
220
  if (!valid.has(key)) {
217
221
  backoff.delete(key)
@@ -222,16 +226,11 @@ async function pruneBackoff(backoff, candidates, ctx, at) {
222
226
  backoff.delete(key)
223
227
  continue
224
228
  }
225
- if (entry?.errorClass !== 'auth' || !entry.credentialFingerprint) continue
226
- const candidate = byKey.get(entry.key)
227
- if (!candidate) continue
228
- let currentFingerprint = credentialCache.get(candidate.key)
229
- if (currentFingerprint === undefined) {
230
- currentFingerprint = await candidateCredentialFingerprint(ctx, candidate)
231
- credentialCache.set(candidate.key, currentFingerprint ?? null)
232
- }
233
- if (credentialFingerprintChanged(entry.credentialFingerprint, currentFingerprint ?? undefined)) {
234
- backoff.delete(key)
229
+ if (entry?.errorClass === 'auth') {
230
+ const currentRef = typeof byKey.get(entry.key)?.endpointCredentialRef === 'string'
231
+ ? byKey.get(entry.key).endpointCredentialRef.trim()
232
+ : ''
233
+ if ((entry.credentialRef ?? '') !== currentRef) backoff.delete(key)
235
234
  }
236
235
  }
237
236
  }
@@ -243,7 +242,6 @@ async function restorePersistentStops(backoff, candidates, backgroundStopStore,
243
242
  if (!Array.isArray(stops) || stops.length === 0) return
244
243
  const byKey = new Map(candidates.map((candidate) => [candidate.key, candidate]))
245
244
  const axes = new Set(orderedAxes())
246
- const credentialCache = new Map()
247
245
  for (const stop of stops) {
248
246
  const candidate = byKey.get(stop?.key)
249
247
  if (!candidate || candidate.endpointFingerprint !== stop?.fingerprint || !axes.has(stop?.axis)) continue
@@ -251,24 +249,14 @@ async function restorePersistentStops(backoff, candidates, backgroundStopStore,
251
249
  try { await backgroundStopStore.clearStop?.(stop.fingerprint, stop.axis) } catch {}
252
250
  continue
253
251
  }
254
- if (stop.errorClass === 'auth' && stop.credentialFingerprint) {
255
- let currentFingerprint = credentialCache.get(candidate.key)
256
- if (currentFingerprint === undefined) {
257
- currentFingerprint = await candidateCredentialFingerprint(ctx, candidate)
258
- credentialCache.set(candidate.key, currentFingerprint ?? null)
259
- }
260
- if (credentialFingerprintChanged(stop.credentialFingerprint, currentFingerprint ?? undefined)) {
261
- try { await backgroundStopStore.clearStop?.(stop.fingerprint, stop.axis) } catch {}
262
- continue
263
- }
264
- }
252
+ const transportScope = transportFailureScope(candidate, stop.errorClass)
265
253
  backoff.set(backoffKey(candidate, stop.axis), {
266
254
  key: candidate.key,
267
255
  fingerprint: candidate.endpointFingerprint,
268
256
  axis: stop.axis,
269
257
  errorClass: stop.errorClass,
270
258
  ...(stop.errorCode ? { errorCode: stop.errorCode } : {}),
271
- ...(stop.credentialFingerprint ? { credentialFingerprint: stop.credentialFingerprint } : {}),
259
+ ...(transportScope ? { transportScope } : {}),
272
260
  retryable: false,
273
261
  until: Number(stop.expiresAt),
274
262
  persisted: true,
@@ -276,14 +264,26 @@ async function restorePersistentStops(backoff, candidates, backgroundStopStore,
276
264
  }
277
265
  }
278
266
 
279
- function candidateBlockedByNonRetryableFailure(backoff, candidate, at) {
267
+ function candidateBlockedByFailure(backoff, candidate, at) {
280
268
  if (!candidate?.endpointFingerprint) return false
269
+ const transportScope = backgroundTransportScope(candidate)
281
270
  for (const entry of backoff.values()) {
282
- if (entry?.retryable !== false) continue
283
271
  const until = Number(entry?.until)
284
272
  if (Number.isFinite(until) && until <= at) continue
285
- if (entry?.key !== candidate.key || entry?.fingerprint !== candidate.endpointFingerprint) continue
286
- if (MODEL_WIDE_NON_RETRYABLE_BACKGROUND_FAILURES.has(entry.errorClass)) return true
273
+
274
+ if (
275
+ entry?.retryable === false &&
276
+ entry?.key === candidate.key &&
277
+ entry?.fingerprint === candidate.endpointFingerprint &&
278
+ MODEL_WIDE_NON_RETRYABLE_BACKGROUND_FAILURES.has(entry.errorClass)
279
+ ) return true
280
+
281
+ if (
282
+ transportScope &&
283
+ entry?.transportScope === transportScope &&
284
+ TRANSPORT_WIDE_BACKGROUND_FAILURES.has(entry.errorClass) &&
285
+ (entry.retryable === false || (Number.isFinite(until) && until > at))
286
+ ) return true
287
287
  }
288
288
  return false
289
289
  }
@@ -307,7 +307,7 @@ async function chooseNextWork({ ctx, config, core, store, now, backoff, excluded
307
307
  excluded.set(candidate.key, { key: candidate.key, reason: 'measured-text-only' })
308
308
  continue
309
309
  }
310
- if (candidateBlockedByNonRetryableFailure(backoff, candidate, now)) continue
310
+ if (candidateBlockedByFailure(backoff, candidate, now)) continue
311
311
  const eligibility = unattendedEligibility(ctx, candidate, authority.backgroundMeasurement, core)
312
312
  if (eligibility.eligible) {
313
313
  candidates.push(candidate)
@@ -674,6 +674,21 @@ export function createBackgroundCapabilityProfiler({
674
674
  }
675
675
  }
676
676
 
677
+ const credentialsChanged = (ref) => {
678
+ const changedRef = typeof ref === 'string' ? ref.trim() : ''
679
+ for (const [key, entry] of backoff.entries()) {
680
+ if (entry?.errorClass !== 'auth') continue
681
+ if (changedRef && entry.credentialRef && entry.credentialRef !== changedRef) continue
682
+ backoff.delete(key)
683
+ }
684
+ topologyDirty = true
685
+ yieldBackground('credential reference changed')
686
+ if (!runningController) {
687
+ topologyDirty = false
688
+ schedule(0)
689
+ }
690
+ }
691
+
677
692
  const tick = async () => {
678
693
  if (stopped || runningController) return
679
694
  const currentNow = Number(now())
@@ -784,22 +799,24 @@ export function createBackgroundCapabilityProfiler({
784
799
  : undefined
785
800
  const until = failure.retryable
786
801
  ? failureAt + transientDelay
787
- : policy.persist === true && persistentTtl !== undefined
802
+ : persistentTtl !== undefined
788
803
  ? failureAt + persistentTtl
789
804
  : undefined
790
- const credentialFingerprint = policy.credentialScoped === true
791
- ? await candidateCredentialFingerprint(ctx, work.candidate)
792
- : undefined
793
805
 
794
806
  if (failure.errorClass === 'unsupported-image') {
795
807
  await markCandidateUnsupported(work.candidate)
796
808
  } else {
809
+ const transportScope = transportFailureScope(work.candidate, failure.errorClass)
810
+ const credentialRef = failure.errorClass === 'auth' && typeof work.candidate.endpointCredentialRef === 'string'
811
+ ? work.candidate.endpointCredentialRef.trim()
812
+ : ''
797
813
  backoff.set(key, {
798
814
  key: work.candidate.key,
799
815
  fingerprint: work.candidate.endpointFingerprint,
800
816
  axis: work.axis,
801
817
  ...failure,
802
- ...(credentialFingerprint ? { credentialFingerprint } : {}),
818
+ ...(transportScope ? { transportScope } : {}),
819
+ ...(credentialRef ? { credentialRef } : {}),
803
820
  ...(until !== undefined ? { until } : {}),
804
821
  })
805
822
  }
@@ -819,7 +836,6 @@ export function createBackgroundCapabilityProfiler({
819
836
  axis: work.axis,
820
837
  errorClass: failure.errorClass,
821
838
  errorCode: failure.errorCode,
822
- credentialFingerprint,
823
839
  recordedAt: failureAt,
824
840
  expiresAt: until,
825
841
  })
@@ -886,6 +902,7 @@ export function createBackgroundCapabilityProfiler({
886
902
  wake,
887
903
  settingsChanged,
888
904
  topologyChanged,
905
+ credentialsChanged,
889
906
  stop,
890
907
  foregroundStart,
891
908
  foregroundEnd,
@@ -980,11 +997,21 @@ export function installBackgroundCapabilityProfiling(ctx, config, core, store, o
980
997
  if (namespace === undefined || String(namespace) === 'vision-router') profiler.settingsChanged()
981
998
  }
982
999
  const wakeForModels = () => profiler.topologyChanged()
1000
+ const credentialAliasLatch = new Set()
1001
+ const wakeForCredentials = (ref) => {
1002
+ const key = typeof ref === 'string' && ref.trim() !== '' ? ref.trim() : '*'
1003
+ if (credentialAliasLatch.has('*') || credentialAliasLatch.has(key) || (key === '*' && credentialAliasLatch.size > 0)) return
1004
+ credentialAliasLatch.add(key)
1005
+ queueMicrotask(() => { credentialAliasLatch.delete(key) })
1006
+ profiler.credentialsChanged(ref)
1007
+ }
983
1008
  try {
984
1009
  if (typeof ctx?.on === 'function') {
985
1010
  for (const [event, handler] of [
986
1011
  ['settings/updated', wakeForSettings],
987
1012
  ['settings/document-updated', wakeForSettings],
1013
+ ['credentials/reference-updated', wakeForCredentials],
1014
+ ['credentials/updated', wakeForCredentials],
988
1015
  ['llm/adapters-updated', wakeForModels],
989
1016
  ]) {
990
1017
  const stop = ctx.on(event, handler)
@@ -7,44 +7,37 @@ const DEFAULT_POLICY = Object.freeze({
7
7
  persist: false,
8
8
  retryAfterMs: undefined,
9
9
  ttlMs: undefined,
10
- credentialScoped: false,
11
10
  })
12
11
 
13
12
  const POLICIES = Object.freeze({
14
13
  auth: Object.freeze({
15
14
  retryable: false,
16
- persist: true,
15
+ persist: false,
17
16
  ttlMs: BACKGROUND_AUTH_STOP_TTL_MS,
18
- credentialScoped: true,
19
17
  }),
20
18
  protocol: Object.freeze({
21
19
  retryable: false,
22
20
  persist: true,
23
21
  ttlMs: BACKGROUND_ENDPOINT_STOP_TTL_MS,
24
- credentialScoped: false,
25
22
  }),
26
23
  unavailable: Object.freeze({
27
24
  retryable: false,
28
25
  persist: true,
29
26
  ttlMs: BACKGROUND_ENDPOINT_STOP_TTL_MS,
30
- credentialScoped: false,
31
27
  }),
32
28
  'unsupported-image': Object.freeze({
33
29
  retryable: false,
34
30
  persist: false,
35
- credentialScoped: false,
36
31
  }),
37
32
  'visual-proof': Object.freeze({
38
33
  retryable: true,
39
34
  persist: false,
40
35
  retryAfterMs: BACKGROUND_TRANSIENT_RETRY_MS,
41
- credentialScoped: false,
42
36
  }),
43
37
  infrastructure: Object.freeze({
44
38
  retryable: true,
45
39
  persist: false,
46
40
  retryAfterMs: BACKGROUND_TRANSIENT_RETRY_MS,
47
- credentialScoped: false,
48
41
  }),
49
42
  })
50
43
 
@@ -52,19 +45,3 @@ const POLICIES = Object.freeze({
52
45
  export function backgroundFailurePolicy(errorClass) {
53
46
  return POLICIES[String(errorClass ?? '')] ?? DEFAULT_POLICY
54
47
  }
55
-
56
- function unresolvedFingerprint(value) {
57
- return typeof value !== 'string' || value === '' || value === 'unresolved'
58
- }
59
-
60
- /**
61
- * Credential transitions are intentionally asymmetric. Losing visibility of a
62
- * previously resolved credential is not proof that the account changed, so the
63
- * old stop remains conservative. Gaining a concrete fingerprint after an
64
- * unresolved state is observable recovery and must release the stale stop.
65
- */
66
- export function credentialFingerprintChanged(stored, current) {
67
- if (unresolvedFingerprint(current)) return false
68
- if (unresolvedFingerprint(stored)) return true
69
- return stored !== current
70
- }
@@ -4,13 +4,13 @@ import process from 'node:process'
4
4
  import { resolveDshHome } from './doctor.js'
5
5
  import { CAPABILITY_BENCHMARK_SUITE_REVISION } from './vision-capability-benchmark.js'
6
6
 
7
- // v1 stops had no expiry and could poison a backend forever. Do not migrate
8
- // them: a cache-version bump intentionally drops that stale authority.
9
- const CACHE_VERSION = 2
7
+ // v1 stops had no expiry; v2 AUTH stops also persisted a secret-derived
8
+ // credential fingerprint. Do not migrate either format: v3 persists only
9
+ // non-secret endpoint/model stop evidence.
10
+ const CACHE_VERSION = 3
10
11
  const MAX_ENTRIES = 256
11
12
  const AXES = new Set(['ocr', 'general', 'document', 'structured', 'grounding'])
12
- const CLASSES = new Set(['auth', 'unavailable', 'protocol'])
13
- const CREDENTIAL_FINGERPRINT_RE = /^(?:cred_[0-9a-f]{24}|unresolved|none)$/
13
+ const CLASSES = new Set(['unavailable', 'protocol'])
14
14
 
15
15
  function cleanText(value, max = 256) {
16
16
  return typeof value === 'string' && value.trim() !== '' ? value.trim().slice(0, max) : ''
@@ -27,13 +27,11 @@ function cleanStop(value) {
27
27
  const axis = cleanText(value.axis, 32)
28
28
  const errorClass = cleanText(value.errorClass, 48)
29
29
  const errorCode = cleanText(value.errorCode, 96)
30
- const credentialFingerprint = cleanText(value.credentialFingerprint, 64)
31
30
  const recordedAt = Number(value.recordedAt)
32
31
  const expiresAt = Number(value.expiresAt)
33
32
  if (!key || !provider || !model || !AXES.has(axis) || !CLASSES.has(errorClass)) return undefined
34
33
  if (!Number.isFinite(recordedAt) || recordedAt <= 0) return undefined
35
34
  if (!Number.isFinite(expiresAt) || expiresAt <= recordedAt) return undefined
36
- if (credentialFingerprint && !CREDENTIAL_FINGERPRINT_RE.test(credentialFingerprint)) return undefined
37
35
  return {
38
36
  fingerprint,
39
37
  key,
@@ -42,7 +40,6 @@ function cleanStop(value) {
42
40
  axis,
43
41
  errorClass,
44
42
  ...(errorCode ? { errorCode } : {}),
45
- ...(errorClass === 'auth' && credentialFingerprint ? { credentialFingerprint } : {}),
46
43
  recordedAt,
47
44
  expiresAt,
48
45
  suiteRevision: CAPABILITY_BENCHMARK_SUITE_REVISION,
@@ -61,11 +58,13 @@ async function load(file, fsOps, at) {
61
58
  try {
62
59
  const raw = await fsOps.readFile(file, 'utf8')
63
60
  const body = JSON.parse(raw)
64
- if (!body || body.version !== CACHE_VERSION || !Array.isArray(body.stops)) return new Map()
65
- const records = body.stops
61
+ if (!body || !Array.isArray(body.stops) || ![2, CACHE_VERSION].includes(body.version)) return new Map()
62
+ const records = new Map(body.stops
66
63
  .map(cleanStop)
67
64
  .filter((item) => item && item.expiresAt > at)
68
- return new Map(records.map((item) => [recordKey(item), item]))
65
+ .map((item) => [recordKey(item), item]))
66
+ if (body.version === 2) await save(file, records, fsOps, at)
67
+ return records
69
68
  } catch (error) {
70
69
  if (error?.code === 'ENOENT') return new Map()
71
70
  return new Map()
@@ -132,7 +131,6 @@ export function createBackgroundBenchmarkStopStore(options = {}) {
132
131
  axis,
133
132
  errorClass,
134
133
  errorCode,
135
- credentialFingerprint,
136
134
  recordedAt = Number(now()),
137
135
  expiresAt,
138
136
  } = {}) {
@@ -146,7 +144,6 @@ export function createBackgroundBenchmarkStopStore(options = {}) {
146
144
  axis,
147
145
  errorClass,
148
146
  errorCode,
149
- credentialFingerprint,
150
147
  recordedAt,
151
148
  expiresAt,
152
149
  suiteRevision: CAPABILITY_BENCHMARK_SUITE_REVISION,
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto'
1
2
  import {
2
3
  capabilityProfileAxisMeasuredAt,
3
4
  createCapabilityProfileStore,
@@ -11,6 +12,8 @@ import {
11
12
  } from './vision-capability-benchmark.js'
12
13
  import { resolveVisionCredential } from './vision-capability-identity.js'
13
14
  import { redactDiagnosticText } from './diagnostic-redaction.js'
15
+ import { wireSessionAffinityId } from './session-affinity.js'
16
+ import { streamWithVisionSessionAffinity } from './session-affinity-runtime.js'
14
17
  import {
15
18
  hardenCapabilityBenchmarkFixture,
16
19
  verifyAndStripBenchmarkVisualProof,
@@ -248,6 +251,14 @@ export function createExactCapabilityInvoker(ctx, core, candidate, config, optio
248
251
  core.callOpenAICompatible(provider, messages, callOptions))
249
252
  const streamExact = options.streamExact ?? ((callOptions) => ctx.llm.stream(callOptions))
250
253
  const now = typeof options.now === 'function' ? options.now : Date.now
254
+ // Benchmarks need routing affinity but are not DSH Sessions. Keep the two
255
+ // identities separate: direct transports receive affinityId, while Host
256
+ // adapters run inside the scoped wire compatibility boundary without a fake
257
+ // GenerateOptions.sessionId that session-aware middleware could mistake for
258
+ // a persisted conversation.
259
+ const requestedAffinityId = wireSessionAffinityId(options.affinityId)
260
+ const benchmarkAffinityId = requestedAffinityId ??
261
+ `vision-benchmark-${(typeof options.randomUUID === 'function' ? options.randomUUID() : randomUUID())}`
251
262
  const prepared = new Map()
252
263
 
253
264
  const prepareFixture = async (fixture) => {
@@ -294,6 +305,7 @@ export function createExactCapabilityInvoker(ctx, core, candidate, config, optio
294
305
  callDirect(exactHttpProvider, openAIMessages(assets.png, assets.prompt), {
295
306
  maxTokens: benchmarkMaxTokensForProvider(exactHttpProvider),
296
307
  signal: callSignal,
308
+ affinityId: benchmarkAffinityId,
297
309
  resolveCredential: (ref) => resolvedCredentialValue(ctx, ref),
298
310
  }),
299
311
  fixtureTimeoutMs,
@@ -318,14 +330,14 @@ export function createExactCapabilityInvoker(ctx, core, candidate, config, optio
318
330
  const started = Number(now())
319
331
  try {
320
332
  output = await withHardDeadline(
321
- collectStreamText(streamExact({
333
+ collectStreamText(streamWithVisionSessionAffinity(benchmarkAffinityId, () => streamExact({
322
334
  provider: candidate.provider,
323
335
  model: candidate.model,
324
336
  messages: assets.messages,
325
337
  maxTokens: DEFAULT_BENCHMARK_MAX_TOKENS,
326
338
  reasoningEffort: undefined,
327
339
  signal: callSignal,
328
- })),
340
+ }))),
329
341
  fixtureTimeoutMs,
330
342
  'capability benchmark adapter fixture timed out',
331
343
  )
@@ -167,11 +167,28 @@ function createRuntimeState(initialConfig = {}) {
167
167
  }
168
168
  }
169
169
 
170
- function stateFor(name, exec) {
170
+ function configuredVisionTaskSignal(config) {
171
+ const timeoutMs = Number(config?.visionTaskTimeoutMs)
172
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return undefined
173
+ return AbortSignal.timeout(Math.max(1, Math.floor(timeoutMs)))
174
+ }
175
+
176
+ function stateFor(name, exec, config) {
177
+ const ambient = currentVisionTurnBudget()
171
178
  return {
172
179
  name,
173
180
  cwd: sessionCwd(exec),
174
- signal: usableSignal(exec?.signal),
181
+ // Own the complete wall-clock task budget at the outermost runtime seam.
182
+ // Some Host services (notably AttachmentStore.saveImage in DSH 0.1.3) do
183
+ // not accept an AbortSignal. executeAbortable still races this signal so a
184
+ // hung Host promise cannot keep the user turn open forever; every wrapped
185
+ // fs/LLM call also observes the same merged cancellation after a late
186
+ // continuation settles.
187
+ signal: combineSignals(
188
+ ambient?.signal,
189
+ usableSignal(exec?.signal),
190
+ configuredVisionTaskSignal(config),
191
+ ),
175
192
  artifactRunId: `.vision-run-${Date.now().toString(36)}-${randomUUID()}`,
176
193
  disableCoreCache: name === 'vision_describe',
177
194
  // Every model call made from a Vision Router tool is a real authority
@@ -557,7 +574,7 @@ async function executeVisionTool(def, args, exec, execute, wrappedCtx, runtimeSt
557
574
  const liveConfig = runtimeState.config()
558
575
  if (liveConfig?.tool === false) throw toolDisabledError(def.name)
559
576
 
560
- const state = stateFor(def.name, exec)
577
+ const state = stateFor(def.name, exec, liveConfig)
561
578
  const releaseArtifactRun = retainArtifactRun(state.artifactRunId)
562
579
  try {
563
580
  return await toolRuntime.run(state, () => withMergedTurnSignal(state, () =>