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.
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/docs/architecture/compat-inventory.md +6 -6
- package/docs/architecture/dsh-compatibility-matrix.md +7 -6
- package/docs/architecture/dsh-support-window.md +36 -16
- package/docs/architecture/p3-compat-retirement.md +7 -5
- package/docs/architecture/p3-host-native-seams.md +3 -1
- package/docs/doctor.md +4 -1
- package/docs/releases/v2.1.4.md +34 -0
- package/docs/v2-capability-routing.md +20 -11
- package/index.js +77 -35
- package/lib/catalog-corrections.js +2 -0
- package/lib/client-presentation-boundary.js +121 -1
- package/lib/doctor-cli-p0.js +3 -1
- package/lib/doctor-cli.js +4 -1
- package/lib/doctor-runtime.js +8 -3
- package/lib/dsh-support-window.js +15 -7
- package/lib/live-model-discovery.js +20 -13
- package/lib/pi-ai-bridge-wire-compat.js +69 -10
- package/lib/session-affinity-runtime.js +104 -0
- package/lib/session-affinity.js +93 -0
- package/lib/tesseract-exec-compat.js +9 -36
- package/lib/vision-backend-runtime-policy.js +1 -0
- package/lib/vision-background-benchmark.js +79 -52
- package/lib/vision-background-failure-policy.js +1 -24
- package/lib/vision-background-stop-store.js +10 -13
- package/lib/vision-capability-benchmark-service.js +14 -2
- package/lib/vision-tool-runtime-boundary.js +20 -3
- package/lib/windows-desktop-capture.js +247 -0
- package/package.json +3 -3
- package/lib/windows-screenshot-dpi-compat.js +0 -148
|
@@ -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
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
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
|
|
@@ -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,
|
|
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
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
|
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
|
-
|
|
286
|
-
if (
|
|
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 (
|
|
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
|
-
:
|
|
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
|
-
...(
|
|
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:
|
|
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
|
|
8
|
-
//
|
|
9
|
-
|
|
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(['
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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, () =>
|