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
|
@@ -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
|
|
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,
|
package/lib/doctor-cli-p0.js
CHANGED
|
@@ -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
|
}
|
package/lib/doctor-runtime.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
`
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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', '
|
|
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.
|
|
234
|
-
*
|
|
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(
|
|
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
|
|
256
|
-
if (
|
|
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
|
-
//
|
|
274
|
-
// exact
|
|
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,
|
|
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
|
+
}
|