dsh-vision-router 3.0.2 → 3.0.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 +13 -11
- package/README.zh.md +11 -9
- package/cordis.patch.yml +14 -10
- package/docs/architecture/dsh-0.2.1-alpha.2-compat.md +41 -0
- package/docs/development/dsh-alpha3-compatibility.md +36 -0
- package/docs/development/dsh-alpha4-adaptation.md +12 -0
- package/docs/development/dsh-alpha4-validation-evidence.md +29 -0
- package/docs/development/dsh-alpha5-adaptation.md +31 -0
- package/docs/development/web-live-reload.md +157 -0
- package/docs/releases/v3.0.3.md +45 -0
- package/docs/releases/v3.0.4.md +18 -0
- package/docs/remote-settings.md +4 -0
- package/docs/v2-capability-routing.md +25 -9
- package/lib/adapter-update-coalescer.js +41 -8
- package/lib/client-presentation-boundary-main.js +46 -13
- package/lib/client.js +30 -24
- package/lib/core-primitives.js +4 -0
- package/lib/diagnostic-redaction.js +9 -3
- package/lib/maintenance-routes.js +7 -1
- package/lib/proxy-routing.js +29 -10
- package/lib/vision-backend-runtime-policy.js +3 -3
- package/lib/vision-background-benchmark.js +33 -5
- package/lib/vision-capability-benchmark-service.js +61 -23
- package/lib/vision-exact-check-client.js +24 -6
- package/lib/vision-image-input-verdict.js +5 -3
- package/lib/vision-toggle-root-hardening.js +20 -3
- package/lib/web-capability-boundary.js +164 -31
- package/package.json +6 -4
- package/presets/web-connection-ready/index.mjs +48 -0
- package/presets/web-connection-ready/package.json +6 -0
|
@@ -10,6 +10,7 @@ import { resolveVisionRoutingAuthority } from './vision-routing-authority.js'
|
|
|
10
10
|
import { redactDiagnosticText } from './diagnostic-redaction.js'
|
|
11
11
|
import { installVisionExactCheckClient } from './vision-exact-check-client.js'
|
|
12
12
|
import { createImageInputVerdictStore } from './vision-image-input-verdict.js'
|
|
13
|
+
import { hostImageDeliveryFromInfo, localOnlyVisionCallAllowed, resolvedHostInfo } from './vision-backend-runtime-policy.js'
|
|
13
14
|
import { createBackgroundBenchmarkStopStore } from './vision-background-stop-store.js'
|
|
14
15
|
import { backgroundFailurePolicy } from './vision-background-failure-policy.js'
|
|
15
16
|
import { isLocalUiRequest } from './web-capability-boundary.js'
|
|
@@ -288,9 +289,18 @@ function candidateBlockedByFailure(backoff, candidate, at) {
|
|
|
288
289
|
return false
|
|
289
290
|
}
|
|
290
291
|
|
|
291
|
-
async function
|
|
292
|
+
async function currentHostImageDelivery(ctx, provider, model) {
|
|
293
|
+
if (provider === 'vision-http') return 'native-image'
|
|
294
|
+
return hostImageDeliveryFromInfo(await resolvedHostInfo(ctx?.llm, { provider, model }))
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function imageVerdictFor(imageVerdictStore, candidate, ctx) {
|
|
292
298
|
if (!candidate?.endpointFingerprint || typeof imageVerdictStore?.get !== 'function') return undefined
|
|
293
|
-
try {
|
|
299
|
+
try {
|
|
300
|
+
const verdict = await imageVerdictStore.get(candidate.endpointFingerprint)
|
|
301
|
+
if (!verdict || verdict.hostMode !== 'native-image') return undefined
|
|
302
|
+
return await currentHostImageDelivery(ctx, candidate.provider, candidate.model) === 'native-image' ? verdict : undefined
|
|
303
|
+
} catch { return undefined }
|
|
294
304
|
}
|
|
295
305
|
|
|
296
306
|
async function chooseNextWork({ ctx, config, core, store, now, backoff, excluded, imageVerdictStore, backgroundStopStore }) {
|
|
@@ -302,7 +312,7 @@ async function chooseNextWork({ ctx, config, core, store, now, backoff, excluded
|
|
|
302
312
|
await restorePersistentStops(backoff, collected, backgroundStopStore, ctx, now)
|
|
303
313
|
const candidates = []
|
|
304
314
|
for (const candidate of collected) {
|
|
305
|
-
const verdict = await imageVerdictFor(imageVerdictStore, candidate)
|
|
315
|
+
const verdict = await imageVerdictFor(imageVerdictStore, candidate, ctx)
|
|
306
316
|
if (verdict?.state === 'unsupported') {
|
|
307
317
|
excluded.set(candidate.key, { key: candidate.key, reason: 'measured-text-only' })
|
|
308
318
|
continue
|
|
@@ -470,6 +480,13 @@ export async function runExactVisionCheck({ ctx, config, core, store, provider,
|
|
|
470
480
|
const wantedModel = typeof model === 'string' ? model.trim() : ''
|
|
471
481
|
if (!wantedProvider || !wantedModel) throw Object.assign(new Error('provider and model are required'), { code: 'VISION_CHECK_BACKEND_REQUIRED' })
|
|
472
482
|
const current = activeSettings(ctx, config)
|
|
483
|
+
if (!localOnlyVisionCallAllowed(core, current, wantedProvider, wantedModel)) {
|
|
484
|
+
const error = new Error('local-only vision policy prevents testing a nonlocal backend')
|
|
485
|
+
error.code = 'VISION_LOCAL_ONLY_POLICY'
|
|
486
|
+
error.benchmarkClass = 'host-configuration'
|
|
487
|
+
throw error
|
|
488
|
+
}
|
|
489
|
+
const declaredBefore = await currentHostImageDelivery(ctx, wantedProvider, wantedModel)
|
|
473
490
|
const candidates = await collectVisionRoutingCandidates(ctx, current, core, store)
|
|
474
491
|
const candidate = candidates.find((entry) => entry?.provider === wantedProvider && entry?.model === wantedModel)
|
|
475
492
|
?? exactAdapterCandidate(ctx, core, wantedProvider, wantedModel)
|
|
@@ -513,6 +530,12 @@ export async function runExactVisionCheck({ ctx, config, core, store, provider,
|
|
|
513
530
|
remaining(),
|
|
514
531
|
'exact image check timed out',
|
|
515
532
|
)
|
|
533
|
+
if (declaredBefore !== await currentHostImageDelivery(ctx, wantedProvider, wantedModel)) {
|
|
534
|
+
const error = new Error('Host image-input declaration changed during the test; retest the model')
|
|
535
|
+
error.code = 'VISION_CHECK_STALE_CONFIG'
|
|
536
|
+
error.benchmarkClass = 'host-configuration'
|
|
537
|
+
throw error
|
|
538
|
+
}
|
|
516
539
|
return {
|
|
517
540
|
ok: true,
|
|
518
541
|
key: candidate.key,
|
|
@@ -602,13 +625,15 @@ export function createBackgroundCapabilityProfiler({
|
|
|
602
625
|
excluded.set(candidate.key, { key: candidate.key, reason: 'measured-text-only' })
|
|
603
626
|
clearDeferredForCandidate(candidate)
|
|
604
627
|
await clearPersistentStopsForCandidate(candidate)
|
|
605
|
-
if (candidate.endpointFingerprint && typeof imageVerdictStore?.markUnsupported === 'function'
|
|
628
|
+
if (candidate.endpointFingerprint && typeof imageVerdictStore?.markUnsupported === 'function'
|
|
629
|
+
&& await currentHostImageDelivery(ctx, candidate.provider, candidate.model) === 'native-image') {
|
|
606
630
|
try {
|
|
607
631
|
await imageVerdictStore.markUnsupported({
|
|
608
632
|
fingerprint: candidate.endpointFingerprint,
|
|
609
633
|
key: candidate.key,
|
|
610
634
|
provider: candidate.provider,
|
|
611
635
|
model: candidate.model,
|
|
636
|
+
hostMode: 'native-image',
|
|
612
637
|
measuredAt: Number(now()),
|
|
613
638
|
})
|
|
614
639
|
} catch (error) {
|
|
@@ -1140,7 +1165,9 @@ export function installBackgroundCapabilityProfiling(ctx, config, core, store, o
|
|
|
1140
1165
|
const timedOut = error?.name === 'TimeoutError' || error?.name === 'AbortError' || /timed out|timeout|deadline/i.test(String(error?.message ?? ''))
|
|
1141
1166
|
const failureClass = error?.benchmarkClass ?? classifyCapabilityBenchmarkFailure(error)
|
|
1142
1167
|
const unsupportedImage = !timedOut && failureClass === 'unsupported-image'
|
|
1143
|
-
if (unsupportedImage
|
|
1168
|
+
if (unsupportedImage && await currentHostImageDelivery(ctx, body.provider, body.model) === 'native-image') {
|
|
1169
|
+
await profiler.recordImageUnsupported(body.provider, body.model)
|
|
1170
|
+
}
|
|
1144
1171
|
const code = timedOut
|
|
1145
1172
|
? 'VISION_CHECK_TIMEOUT'
|
|
1146
1173
|
: unsupportedImage
|
|
@@ -1151,6 +1178,7 @@ export function installBackgroundCapabilityProfiling(ctx, config, core, store, o
|
|
|
1151
1178
|
'VISION_CHECK_INVALID_JSON',
|
|
1152
1179
|
'VISION_CHECK_BACKEND_REQUIRED',
|
|
1153
1180
|
'VISION_CHECK_BACKEND_STALE',
|
|
1181
|
+
'VISION_CHECK_STALE_CONFIG',
|
|
1154
1182
|
]).has(code)
|
|
1155
1183
|
sendJson(res, unsupportedImage ? 422 : clientError ? 400 : 502, {
|
|
1156
1184
|
ok: false,
|
|
@@ -14,6 +14,8 @@ import { resolveVisionCredential } from './vision-capability-identity.js'
|
|
|
14
14
|
import { redactDiagnosticText } from './diagnostic-redaction.js'
|
|
15
15
|
import { wireSessionAffinityId } from './session-affinity.js'
|
|
16
16
|
import { streamWithVisionSessionAffinity } from './session-affinity-runtime.js'
|
|
17
|
+
import { hostImageDeliveryFromInfo, resolvedHostInfo } from './vision-backend-runtime-policy.js'
|
|
18
|
+
import { isLocalImageCapabilityAdmissionFailure } from './vision-execution-policy.js'
|
|
17
19
|
import { streamWithLegacyGlobalProxyScope } from './legacy-global-proxy-boundary.js'
|
|
18
20
|
import {
|
|
19
21
|
hardenCapabilityBenchmarkFixture,
|
|
@@ -205,13 +207,28 @@ function failureError(failure, fallback) {
|
|
|
205
207
|
async function collectStreamText(streamLike) {
|
|
206
208
|
const stream = await streamLike
|
|
207
209
|
let text = ''
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
210
|
+
let observedOutput = false
|
|
211
|
+
try {
|
|
212
|
+
for await (const chunk of stream) {
|
|
213
|
+
// Any non-terminal output proves that the adapter began responding.
|
|
214
|
+
// Retrying through a second transport could duplicate a billed call.
|
|
215
|
+
if (chunk && chunk.type !== 'finish' && chunk.type !== 'error' && chunk.type !== 'aborted') {
|
|
216
|
+
observedOutput = true
|
|
217
|
+
}
|
|
218
|
+
if (chunk && typeof chunk.text === 'string') text += chunk.text
|
|
219
|
+
if (chunk?.type === 'finish') {
|
|
220
|
+
const kind = chunk.reason?.kind
|
|
221
|
+
if (kind === 'error' || kind === 'aborted') throw failureError(chunk.reason?.failure, kind)
|
|
222
|
+
}
|
|
223
|
+
if (chunk?.type === 'error' || chunk?.type === 'aborted') throw failureError(chunk.failure, chunk.type)
|
|
224
|
+
}
|
|
225
|
+
} catch (error) {
|
|
226
|
+
if (observedOutput) {
|
|
227
|
+
const failure = failureError(error)
|
|
228
|
+
failure.visionAdapterProducedOutput = true
|
|
229
|
+
throw failure
|
|
213
230
|
}
|
|
214
|
-
|
|
231
|
+
throw error
|
|
215
232
|
}
|
|
216
233
|
return text
|
|
217
234
|
}
|
|
@@ -223,20 +240,22 @@ function tagInvocationError(value) {
|
|
|
223
240
|
return error
|
|
224
241
|
}
|
|
225
242
|
|
|
226
|
-
function mayUseExactAdapterBridge(
|
|
227
|
-
if (directProvider === undefined) return false
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
243
|
+
function mayUseExactAdapterBridge(_core, error, directProvider) {
|
|
244
|
+
if (directProvider === undefined || error?.visionAdapterProducedOutput === true) return false
|
|
245
|
+
// Share the runtime's exact pre-wire pi-ai admission predicate. Provider
|
|
246
|
+
// HTTP 400s, ambiguous image errors and network failures must never replay.
|
|
247
|
+
return isLocalImageCapabilityAdmissionFailure(error)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function exactHostImageDeliveryPlan(info, candidate, core) {
|
|
251
|
+
const delivery = hostImageDeliveryFromInfo(info)
|
|
252
|
+
if (delivery !== 'text-projected') return { delivery, path: 'adapter' }
|
|
253
|
+
const verified = candidate?.evidenceScope === 'endpoint'
|
|
254
|
+
&& candidate?.endpointConfig?.api === 'openai-completions'
|
|
255
|
+
&& typeof candidate?.endpoint === 'string'
|
|
256
|
+
&& typeof core?.isOpenAIHttpBridgeTransport === 'function'
|
|
257
|
+
&& core.isOpenAIHttpBridgeTransport({ api: 'openai-completions', baseURL: candidate.endpoint })
|
|
258
|
+
return { delivery, path: verified ? 'http-direct' : 'refuse' }
|
|
240
259
|
}
|
|
241
260
|
|
|
242
261
|
function benchmarkMaxTokensForProvider(provider) {
|
|
@@ -295,6 +314,22 @@ export function createExactCapabilityInvoker(ctx, core, candidate, config, optio
|
|
|
295
314
|
)
|
|
296
315
|
const callSignal = mergedSignal(signal, AbortSignal.timeout(fixtureTimeoutMs))
|
|
297
316
|
const exactHttpProvider = directProviderFor(candidate, config, core)
|
|
317
|
+
const hostInfo = candidate.provider === 'vision-http' ? undefined :
|
|
318
|
+
await resolvedHostInfo(ctx?.llm, { provider: candidate.provider, model: candidate.model, signal: callSignal })
|
|
319
|
+
const imagePlan = candidate.provider === 'vision-http'
|
|
320
|
+
? { path: 'http-direct' }
|
|
321
|
+
: exactHostImageDeliveryPlan(hostInfo, candidate, core)
|
|
322
|
+
if (imagePlan.path === 'refuse') {
|
|
323
|
+
const error = new Error('Host declares this model text-only and removes image pixels before adapter dispatch; enable Image in DSH model input types, then retest')
|
|
324
|
+
error.code = 'VISION_IMAGE_DELIVERY_UNAVAILABLE'
|
|
325
|
+
error.benchmarkClass = 'host-configuration'
|
|
326
|
+
throw error
|
|
327
|
+
}
|
|
328
|
+
if (imagePlan.path === 'http-direct' && exactHttpProvider === undefined) {
|
|
329
|
+
const error = new Error('verified HTTP image bridge unavailable for this model')
|
|
330
|
+
error.code = 'CAPABILITY_BENCHMARK_PROTOCOL'
|
|
331
|
+
throw error
|
|
332
|
+
}
|
|
298
333
|
const callExactHttp = async () => {
|
|
299
334
|
if (exactHttpProvider === undefined) {
|
|
300
335
|
const error = new Error('exact HTTP bridge is unavailable for this benchmark backend')
|
|
@@ -323,7 +358,7 @@ export function createExactCapabilityInvoker(ctx, core, candidate, config, optio
|
|
|
323
358
|
let output
|
|
324
359
|
let transport
|
|
325
360
|
let latencyMs
|
|
326
|
-
if (candidate.provider === 'vision-http') {
|
|
361
|
+
if (candidate.provider === 'vision-http' || imagePlan.path === 'http-direct') {
|
|
327
362
|
const direct = await callExactHttp()
|
|
328
363
|
output = direct.output
|
|
329
364
|
latencyMs = direct.latencyMs
|
|
@@ -404,8 +439,11 @@ async function declaredImageState(ctx, core, config, candidate) {
|
|
|
404
439
|
candidate.model,
|
|
405
440
|
config?.extraVisionModels,
|
|
406
441
|
)
|
|
407
|
-
|
|
408
|
-
if (
|
|
442
|
+
const hostMode = hostImageDeliveryFromInfo(info)
|
|
443
|
+
if (hostMode === 'native-image') return 'declared'
|
|
444
|
+
if (hostMode === 'text-projected') return 'text-only'
|
|
445
|
+
// Name-based guesses and user labels do not rewrite Host input metadata.
|
|
446
|
+
if (decision?.image === true) return 'unknown'
|
|
409
447
|
} catch {
|
|
410
448
|
// Metadata is advisory. Exact configured routes remain force-testable.
|
|
411
449
|
}
|
|
@@ -80,6 +80,9 @@ export const VISION_EXACT_CHECK_CLIENT = String.raw`(function(){
|
|
|
80
80
|
VISION_CHECK_BACKEND_STALE:[text('当前模型暂时无法直接调用,请确认供应商/模型仍可用后重试','This model is not currently callable; verify the provider/model and retry')],
|
|
81
81
|
VISION_CHECK_TIMEOUT:[text('测试超时,已自动结束','Test timed out and was stopped')],
|
|
82
82
|
VISION_CHECK_UNSUPPORTED_IMAGE:[text('实测仅文本 · 图片请求被模型拒绝','Measured text-only · the model rejected image input')],
|
|
83
|
+
VISION_IMAGE_DELIVERY_UNAVAILABLE:[text('Host未声明图片输入,请在DSH模型设置中启用Image再测','Host declares text-only input; enable Image in DSH Models settings and retest')],
|
|
84
|
+
VISION_CHECK_STALE_CONFIG:[text('测试期间模型配置已变化,请重新测试','Model input settings changed during the test; retest required')],
|
|
85
|
+
VISION_LOCAL_ONLY_POLICY:[text('本地识图模式下不允许测试非本地后端','Local-only vision policy blocks testing nonlocal backends')],
|
|
83
86
|
VISION_CHECK_INFRASTRUCTURE:[text('识图测试组件暂不可用','Vision check infrastructure is unavailable')],
|
|
84
87
|
CAPABILITY_BENCHMARK_INFRASTRUCTURE:[text('识图测试组件暂不可用','Vision check infrastructure is unavailable')],
|
|
85
88
|
CAPABILITY_BENCHMARK_VISUAL_PROOF_FAILED:[text('模型没有正确读出测试图','The model did not correctly inspect the test image')]
|
|
@@ -91,9 +94,16 @@ export const VISION_EXACT_CHECK_CLIENT = String.raw`(function(){
|
|
|
91
94
|
}
|
|
92
95
|
function reset(control){
|
|
93
96
|
if(!control)return;
|
|
97
|
+
delete control.dataset.lastResult;
|
|
94
98
|
setStatus(control,text('快速自检 · 1次请求 · 只验证当前模型能否识图 · 不写入Auto能力分数','Quick check · 1 request · verifies image input only · does not write Auto capability scores'));
|
|
95
99
|
setButton(control,text('测试识图','Test vision'),false);
|
|
96
100
|
}
|
|
101
|
+
function makeRemoteNotice(row){
|
|
102
|
+
var note=document.createElement('div');note.setAttribute(CONTROL_ATTR,'remote');
|
|
103
|
+
note.style.cssText='flex:1 0 100%;font-size:12px;opacity:.7;margin-top:4px';
|
|
104
|
+
note.textContent=text('识图自检仅可在DSH本机界面执行;远程页面不发送测试请求。','Vision testing is local-only. Open the DSH UI on the Host to run the image check.');
|
|
105
|
+
row.appendChild(note);return note;
|
|
106
|
+
}
|
|
97
107
|
function makeControl(row){
|
|
98
108
|
if(!row||!row.style)return undefined;
|
|
99
109
|
row.style.flexWrap='wrap';
|
|
@@ -131,15 +141,15 @@ export const VISION_EXACT_CHECK_CLIENT = String.raw`(function(){
|
|
|
131
141
|
if(!currentRun(control,token,key))return;
|
|
132
142
|
if(!response.ok||!body||body.ok!==true){
|
|
133
143
|
var raw=String(body&&(body.error||body.code)||('HTTP '+response.status));
|
|
134
|
-
setStatus(control,'✗ '+selection.provider+'/'+selection.model+' · '+failureMessage(body,response.status),raw);notifyChanged();return;
|
|
144
|
+
setStatus(control,'✗ '+selection.provider+'/'+selection.model+' · '+failureMessage(body,response.status),raw);control.dataset.lastResult=String(Date.now());notifyChanged();return;
|
|
135
145
|
}
|
|
136
146
|
var via=transport(body.transport),latency=seconds(body.latencyMs),output=short(body.output,72);
|
|
137
147
|
var suffix=(via?' · '+via:'')+(latency?' · '+latency:'')+(output?' · '+output:'');
|
|
138
|
-
setStatus(control,'✓ '+selection.provider+'/'+selection.model+text(' · 图片识别正常',' · image verified')+suffix);notifyChanged();
|
|
148
|
+
setStatus(control,'✓ '+selection.provider+'/'+selection.model+text(' · 图片识别正常',' · image verified')+suffix+' · '+new Date().toLocaleTimeString());control.dataset.lastResult=String(Date.now());notifyChanged();
|
|
139
149
|
}catch(error){
|
|
140
150
|
if(!currentRun(control,token,key))return;
|
|
141
151
|
var message=error&&error.name==='AbortError'?text('测试超时,已自动结束','Test timed out and was stopped'):text('测试失败','Vision check failed');
|
|
142
|
-
setStatus(control,'✗ '+selection.provider+'/'+selection.model+' · '+message,String(error&&error.message||error));
|
|
152
|
+
setStatus(control,'✗ '+selection.provider+'/'+selection.model+' · '+message,String(error&&error.message||error));control.dataset.lastResult=String(Date.now());
|
|
143
153
|
}finally{
|
|
144
154
|
if(timer!==undefined)clearTimeout(timer);
|
|
145
155
|
if(ticker!==undefined)clearInterval(ticker);
|
|
@@ -148,12 +158,13 @@ export const VISION_EXACT_CHECK_CLIENT = String.raw`(function(){
|
|
|
148
158
|
}
|
|
149
159
|
}
|
|
150
160
|
function scan(){
|
|
151
|
-
|
|
161
|
+
var local=localPage();
|
|
152
162
|
var rows=[];try{rows=Array.prototype.slice.call(document.querySelectorAll(ROW_SELECTOR));}catch(_){rows=[];}
|
|
153
163
|
rows.forEach(function(row){
|
|
154
164
|
var selection=rowSelection(row),control=row.querySelector('['+CONTROL_ATTR+']');
|
|
155
165
|
if(!complete(selection)){if(control){abortActive(control);if(control.remove)control.remove();}return;}
|
|
156
|
-
if(!control)control=makeControl(row);
|
|
166
|
+
if(!control)control=local?makeControl(row):makeRemoteNotice(row);
|
|
167
|
+
if(!local)return;
|
|
157
168
|
var key=selectionKey(selection);
|
|
158
169
|
if(control&&control.dataset.selection!==key){abortActive(control);control.dataset.selection=key;reset(control);}
|
|
159
170
|
});
|
|
@@ -167,8 +178,15 @@ export const VISION_EXACT_CHECK_CLIENT = String.raw`(function(){
|
|
|
167
178
|
}catch(_){}
|
|
168
179
|
return false;
|
|
169
180
|
}
|
|
170
|
-
function
|
|
181
|
+
function invalidatePreviousResults(){
|
|
171
182
|
if(!localPage())return;
|
|
183
|
+
var controls=document.querySelectorAll('['+CONTROL_ATTR+'][data-last-result]');
|
|
184
|
+
controls.forEach(function(control){abortActive(control);reset(control);
|
|
185
|
+
setStatus(control,text('配置可能已变化,请重新测试','Configuration may have changed; retest required'));
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
function start(){
|
|
189
|
+
window.addEventListener('focus',invalidatePreviousResults);
|
|
172
190
|
document.addEventListener('change',function(event){var row=event&&event.target&&event.target.closest?event.target.closest(ROW_SELECTOR):undefined;if(row)schedule();},true);
|
|
173
191
|
if(typeof MutationObserver==='function'&&document.documentElement){
|
|
174
192
|
var observer=new MutationObserver(function(records){
|
|
@@ -5,7 +5,7 @@ import process from 'node:process'
|
|
|
5
5
|
import { resolveDshHome } from './doctor.js'
|
|
6
6
|
import { CAPABILITY_BENCHMARK_SUITE_REVISION } from './vision-capability-benchmark.js'
|
|
7
7
|
|
|
8
|
-
const CACHE_VERSION =
|
|
8
|
+
const CACHE_VERSION = 2
|
|
9
9
|
const MAX_ENTRIES = 128
|
|
10
10
|
|
|
11
11
|
function cleanText(value, max = 256) {
|
|
@@ -14,7 +14,7 @@ function cleanText(value, max = 256) {
|
|
|
14
14
|
|
|
15
15
|
function cleanVerdict(value) {
|
|
16
16
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
|
17
|
-
if (value.state !== 'unsupported') return undefined
|
|
17
|
+
if (value.state !== 'unsupported' || value.hostMode !== 'native-image') return undefined
|
|
18
18
|
if (Number(value.suiteRevision) !== CAPABILITY_BENCHMARK_SUITE_REVISION) return undefined
|
|
19
19
|
const fingerprint = cleanText(value.fingerprint, 64)
|
|
20
20
|
if (!/^ep2_[0-9a-f]{32}$/.test(fingerprint)) return undefined
|
|
@@ -29,6 +29,7 @@ function cleanVerdict(value) {
|
|
|
29
29
|
provider,
|
|
30
30
|
model,
|
|
31
31
|
state: 'unsupported',
|
|
32
|
+
hostMode: 'native-image',
|
|
32
33
|
reason: 'provider-rejected-image',
|
|
33
34
|
measuredAt,
|
|
34
35
|
suiteRevision: CAPABILITY_BENCHMARK_SUITE_REVISION,
|
|
@@ -103,7 +104,7 @@ export function createImageInputVerdictStore(options = {}) {
|
|
|
103
104
|
await ready
|
|
104
105
|
return records.get(String(fingerprint ?? ''))
|
|
105
106
|
},
|
|
106
|
-
async markUnsupported({ fingerprint, key, provider, model, measuredAt = Date.now() } = {}) {
|
|
107
|
+
async markUnsupported({ fingerprint, key, provider, model, hostMode, measuredAt = Date.now() } = {}) {
|
|
107
108
|
await ready
|
|
108
109
|
const clean = cleanVerdict({
|
|
109
110
|
fingerprint,
|
|
@@ -111,6 +112,7 @@ export function createImageInputVerdictStore(options = {}) {
|
|
|
111
112
|
provider,
|
|
112
113
|
model,
|
|
113
114
|
state: 'unsupported',
|
|
115
|
+
hostMode,
|
|
114
116
|
measuredAt,
|
|
115
117
|
suiteRevision: CAPABILITY_BENCHMARK_SUITE_REVISION,
|
|
116
118
|
})
|
|
@@ -345,7 +345,15 @@ const CLIENT_PRELUDE = String.raw`(function(){
|
|
|
345
345
|
})();`
|
|
346
346
|
|
|
347
347
|
function replaceAll(source, before, after) {
|
|
348
|
-
|
|
348
|
+
let result = source.includes(before) ? source.split(before).join(after) : source
|
|
349
|
+
// Windows git checkouts can put CRLF inside injected client-source template
|
|
350
|
+
// literals. Match only the same owned seam with CRLF; do not normalize or
|
|
351
|
+
// rewrite the surrounding HTML or any unrelated plugin's source.
|
|
352
|
+
if (before.includes('\n')) {
|
|
353
|
+
const windowsBefore = before.replaceAll('\n', '\r\n')
|
|
354
|
+
if (result.includes(windowsBefore)) result = result.split(windowsBefore).join(after)
|
|
355
|
+
}
|
|
356
|
+
return result
|
|
349
357
|
}
|
|
350
358
|
|
|
351
359
|
export function hardenVisionClientSource(source) {
|
|
@@ -403,7 +411,7 @@ export function hardenVisionClientSource(source) {
|
|
|
403
411
|
next = replaceAll(
|
|
404
412
|
next,
|
|
405
413
|
` var message = latest && typeof latest.error === 'string' && latest.error !== ''\n ? latest.error\n : t('failedUnknown');`,
|
|
406
|
-
` var
|
|
414
|
+
` var recoveryStore = typeof recovery === 'undefined' ? null : recovery;\n if (!recoveryStore && typeof window !== 'undefined') {\n var hardening = window.__dshVisionRouterRootHardening;\n if (hardening && typeof hardening.recoveryFor === 'function') recoveryStore = hardening.recoveryFor(directory);\n }\n var recovered = recoveryStore && typeof recoveryStore.getSnapshot === 'function' ? recoveryStore.getSnapshot() : null;\n var message = latest && typeof latest.error === 'string' && latest.error !== ''\n ? latest.error\n : recovered && recovered.message ? recovered.message : t('failedUnknown');`,
|
|
407
415
|
)
|
|
408
416
|
|
|
409
417
|
next = replaceAll(
|
|
@@ -431,10 +439,19 @@ export function hardenVisionClientSource(source) {
|
|
|
431
439
|
)
|
|
432
440
|
|
|
433
441
|
next = replaceAll(next, `function wrapStore(store, settings) {`, `function wrapStore(store, settings, recovery) {`)
|
|
442
|
+
// A synthetic recovery snapshot must retain referential identity until
|
|
443
|
+
// its Host source snapshot or recovery error actually changes. Otherwise
|
|
444
|
+
// React's useSyncExternalStore sees a new snapshot on every read and can
|
|
445
|
+
// enter a maximum-update-depth loop after a failed select + catalog refresh.
|
|
446
|
+
next = replaceAll(
|
|
447
|
+
next,
|
|
448
|
+
` var lastProjected;\n function getSnapshot() {`,
|
|
449
|
+
` var lastProjected;\n var lastRecoverySource;\n var lastRecoveryError;\n var lastRecoveryRaw;\n function getSnapshot() {`,
|
|
450
|
+
)
|
|
434
451
|
next = replaceAll(
|
|
435
452
|
next,
|
|
436
453
|
` var raw = typeof store.getSnapshot === 'function' ? store.getSnapshot() : undefined;\n var config = currentVisionConfig(settings);`,
|
|
437
|
-
` var raw = typeof store.getSnapshot === 'function' ? store.getSnapshot() : undefined;\n var recovered = recovery && typeof recovery.getSnapshot === 'function' ? recovery.getSnapshot() : null;\n if (recovered && raw && (raw.status === 'selecting' || raw.status === 'loading')) {\n raw = Object.assign({}, raw, { status: 'error', error: recovered.message || String(recovered) });\n }\n var config = currentVisionConfig(settings);`,
|
|
454
|
+
` var raw = typeof store.getSnapshot === 'function' ? store.getSnapshot() : undefined;\n var recovered = recovery && typeof recovery.getSnapshot === 'function' ? recovery.getSnapshot() : null;\n if (recovered && raw && (raw.status === 'selecting' || raw.status === 'loading')) {\n if (raw !== lastRecoverySource || recovered !== lastRecoveryError) {\n lastRecoverySource = raw;\n lastRecoveryError = recovered;\n lastRecoveryRaw = Object.assign({}, raw, { status: 'error', error: recovered.message || String(recovered) });\n }\n raw = lastRecoveryRaw;\n }\n var config = currentVisionConfig(settings);`,
|
|
438
455
|
)
|
|
439
456
|
next = replaceAll(
|
|
440
457
|
next,
|
|
@@ -126,17 +126,42 @@ function isLocalHostName(value) {
|
|
|
126
126
|
return host === 'localhost' || host.endsWith('.localhost') || isLoopbackAddress(host)
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
// Forwarding metadata can only *deny* a local-machine capability. Never
|
|
130
|
+
// interpret X-Forwarded-For or similar caller-controlled headers as proof
|
|
131
|
+
// that a request really originated at the local browser.
|
|
132
|
+
const PROXY_PROVENANCE_HEADERS = new Set([
|
|
133
|
+
'forwarded',
|
|
134
|
+
'x-forwarded-for',
|
|
135
|
+
'x-forwarded-host',
|
|
136
|
+
'x-forwarded-proto',
|
|
137
|
+
'x-real-ip',
|
|
138
|
+
'x-client-ip',
|
|
139
|
+
'true-client-ip',
|
|
140
|
+
'cf-connecting-ip',
|
|
141
|
+
'via',
|
|
142
|
+
])
|
|
143
|
+
|
|
144
|
+
function hasProxyProvenance(req) {
|
|
145
|
+
const headers = req?.headers
|
|
146
|
+
return !!headers && typeof headers === 'object' &&
|
|
147
|
+
Object.keys(headers).some((name) => PROXY_PROVENANCE_HEADERS.has(name.toLowerCase()))
|
|
148
|
+
}
|
|
149
|
+
|
|
129
150
|
/**
|
|
130
151
|
* Browser-facing local capability check. A real network request needs BOTH a
|
|
131
|
-
* loopback TCP peer and a loopback/localhost Host header
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
152
|
+
* loopback TCP peer and a loopback/localhost Host header, with no forwarding
|
|
153
|
+
* metadata. This protects supported proxy deployments preserving external Host
|
|
154
|
+
* and fails closed when a proxy rewrites Host but keeps standard hop markers.
|
|
155
|
+
* A proxy that erases every originating fact is indistinguishable from a
|
|
156
|
+
* genuine loopback browser request; these headers are not an attestation.
|
|
157
|
+
* Internal direct handler calls have no socket and remain supported.
|
|
135
158
|
*/
|
|
136
159
|
export function isLocalUiRequest(req) {
|
|
137
160
|
const transport = req?.socket ?? req?.connection
|
|
138
161
|
if (!transport) return true
|
|
139
|
-
return isLoopbackAddress(transport.remoteAddress) &&
|
|
162
|
+
return isLoopbackAddress(transport.remoteAddress) &&
|
|
163
|
+
isLocalHostName(requestHostName(req)) &&
|
|
164
|
+
!hasProxyProvenance(req)
|
|
140
165
|
}
|
|
141
166
|
|
|
142
167
|
export function isLocalMutationRoute(path, method) {
|
|
@@ -204,40 +229,148 @@ function redactRemoteBody(path, body) {
|
|
|
204
229
|
return body
|
|
205
230
|
}
|
|
206
231
|
|
|
207
|
-
|
|
232
|
+
// Remote metadata is small. Never buffer an unbounded response from a future
|
|
233
|
+
// handler revision: local-only diagnostics should not turn into remote memory DoS.
|
|
234
|
+
const MAX_REMOTE_REDACTION_BYTES = 1024 * 1024
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* A remote response requiring redaction must be completely validated *before*
|
|
238
|
+
* emitting headers or bytes. Unlike intercepting end() alone, this also covers
|
|
239
|
+
* write()+end(), malformed JSON, oversize bodies and async stream completion.
|
|
240
|
+
* No handler may transmit its raw token, path or other local fields while the
|
|
241
|
+
* remote body remains unvalidated.
|
|
242
|
+
*/
|
|
208
243
|
function runWithRemoteReadRedaction(path, handler, req, res) {
|
|
209
244
|
const originalEnd = res?.end
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
245
|
+
const originalWrite = res?.write
|
|
246
|
+
const originalWriteHead = res?.writeHead
|
|
247
|
+
const originalFlushHeaders = res?.flushHeaders
|
|
248
|
+
if (typeof originalEnd !== 'function' || typeof originalWriteHead !== 'function') {
|
|
249
|
+
throw new TypeError('vision-router: remote redaction requires a buffered HTTP response')
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const chunks = []
|
|
253
|
+
let bytes = 0
|
|
254
|
+
let done = false
|
|
255
|
+
let head
|
|
256
|
+
const restore = () => {
|
|
257
|
+
if (res.end === redactedEnd) res.end = originalEnd
|
|
258
|
+
if (res.write === redactedWrite) {
|
|
259
|
+
if (originalWrite === undefined) delete res.write
|
|
260
|
+
else res.write = originalWrite
|
|
261
|
+
}
|
|
262
|
+
if (res.writeHead === redactedWriteHead) res.writeHead = originalWriteHead
|
|
263
|
+
if (res.flushHeaders === redactedFlushHeaders) {
|
|
264
|
+
if (originalFlushHeaders === undefined) delete res.flushHeaders
|
|
265
|
+
else res.flushHeaders = originalFlushHeaders
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const stripStagedHeaders = () => {
|
|
269
|
+
// Route handlers own the pre-redaction response. Even header values may
|
|
270
|
+
// contain future local-only tokens, paths or Set-Cookie material.
|
|
271
|
+
// Never forward any unreviewed staged header to a remote client.
|
|
215
272
|
try {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
}
|
|
221
|
-
} catch {
|
|
222
|
-
// If a future handler stops returning JSON, do not corrupt its response.
|
|
273
|
+
for (const key of res.getHeaderNames?.() ?? []) res.removeHeader?.(key)
|
|
274
|
+
} catch { /* a test double may not expose the header table */ }
|
|
275
|
+
for (const key of ['content-length', 'content-encoding', 'transfer-encoding']) {
|
|
276
|
+
try { res.removeHeader?.(key) } catch {}
|
|
223
277
|
}
|
|
224
|
-
return originalEnd.call(this, next, ...args)
|
|
225
278
|
}
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
279
|
+
const safeHeaders = () => ({
|
|
280
|
+
'content-type': 'application/json; charset=utf-8',
|
|
281
|
+
'cache-control': 'no-store',
|
|
282
|
+
})
|
|
283
|
+
const complete = (body, rejected = false, callback) => {
|
|
284
|
+
if (done) return res
|
|
285
|
+
done = true
|
|
286
|
+
// On rejection, deliberately retain the buffered wrappers after sending
|
|
287
|
+
// the safe error. A drifting async handler may continue calling write()
|
|
288
|
+
// or end(); restoring the raw methods here would expose those later bytes.
|
|
289
|
+
// The response is already ended and these wrappers swallow late writes.
|
|
290
|
+
if (!rejected) restore()
|
|
291
|
+
stripStagedHeaders()
|
|
292
|
+
if (rejected) {
|
|
293
|
+
// An invalid or oversized response is an internal gateway failure. Do
|
|
294
|
+
// not repeat the untrusted data or send previously staged headers.
|
|
295
|
+
originalWriteHead.call(res, 502, safeHeaders())
|
|
296
|
+
return originalEnd.call(res, JSON.stringify({
|
|
297
|
+
ok: false,
|
|
298
|
+
error: 'remote response could not be safely redacted',
|
|
299
|
+
}), callback)
|
|
300
|
+
}
|
|
301
|
+
if (head) {
|
|
302
|
+
originalWriteHead.call(res, head.status, safeHeaders())
|
|
303
|
+
} else {
|
|
304
|
+
originalWriteHead.call(res, Number.isInteger(res.statusCode) ? res.statusCode : 200, safeHeaders())
|
|
305
|
+
}
|
|
306
|
+
return originalEnd.call(res, body, callback)
|
|
230
307
|
}
|
|
231
|
-
|
|
308
|
+
const append = (chunk, encoding) => {
|
|
309
|
+
if (chunk === undefined || chunk === null) return true
|
|
310
|
+
let value
|
|
311
|
+
try {
|
|
312
|
+
if (Buffer.isBuffer(chunk)) value = chunk
|
|
313
|
+
else if (typeof chunk === 'string') value = Buffer.from(chunk, typeof encoding === 'string' ? encoding : 'utf8')
|
|
314
|
+
else if (chunk instanceof Uint8Array) value = Buffer.from(chunk)
|
|
315
|
+
else return false
|
|
316
|
+
} catch { return false }
|
|
317
|
+
if (value.length > MAX_REMOTE_REDACTION_BYTES - bytes) return false
|
|
318
|
+
bytes += value.length
|
|
319
|
+
chunks.push(value)
|
|
320
|
+
return true
|
|
321
|
+
}
|
|
322
|
+
const failed = () => complete(undefined, true)
|
|
323
|
+
function redactedWriteHead(status, maybeMessage, maybeHeaders) {
|
|
324
|
+
if (done) return this
|
|
325
|
+
const message = typeof maybeMessage === 'string' ? maybeMessage : undefined
|
|
326
|
+
const headers = message === undefined ? maybeMessage : maybeHeaders
|
|
327
|
+
if (head || !Number.isInteger(status) || status < 100 || status > 599 ||
|
|
328
|
+
(headers !== undefined && (!headers || typeof headers !== 'object' || Array.isArray(headers)))) {
|
|
329
|
+
failed()
|
|
330
|
+
return this
|
|
331
|
+
}
|
|
332
|
+
head = { status, message, headers }
|
|
333
|
+
return this
|
|
334
|
+
}
|
|
335
|
+
function redactedFlushHeaders() {
|
|
336
|
+
// Deferring the header is essential: once sent, an invalid JSON body
|
|
337
|
+
// cannot be replaced by a safe 502.
|
|
338
|
+
}
|
|
339
|
+
function redactedWrite(chunk, encoding, callback) {
|
|
340
|
+
const completeWrite = typeof encoding === 'function' ? encoding : callback
|
|
341
|
+
if (!done && !append(chunk, encoding)) failed()
|
|
342
|
+
if (typeof completeWrite === 'function') queueMicrotask(completeWrite)
|
|
343
|
+
return !done
|
|
344
|
+
}
|
|
345
|
+
function redactedEnd(chunk, encoding, callback) {
|
|
346
|
+
if (done) return res
|
|
347
|
+
const completeEnd = typeof encoding === 'function' ? encoding : callback
|
|
348
|
+
if (!append(chunk, encoding)) return failed()
|
|
349
|
+
try {
|
|
350
|
+
const body = JSON.parse(Buffer.concat(chunks, bytes).toString('utf8'))
|
|
351
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return failed()
|
|
352
|
+
const redacted = JSON.stringify(redactRemoteBody(path, body))
|
|
353
|
+
if (Buffer.byteLength(redacted, 'utf8') > MAX_REMOTE_REDACTION_BYTES) return failed()
|
|
354
|
+
return complete(redacted, false, completeEnd)
|
|
355
|
+
} catch {
|
|
356
|
+
return failed()
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
res.writeHead = redactedWriteHead
|
|
361
|
+
res.write = redactedWrite
|
|
362
|
+
res.end = redactedEnd
|
|
363
|
+
res.flushHeaders = redactedFlushHeaders
|
|
232
364
|
try {
|
|
233
|
-
result = handler(req, res)
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
365
|
+
const result = handler(req, res)
|
|
366
|
+
if (result && typeof result.then === 'function') {
|
|
367
|
+
return Promise.resolve(result).catch(() => { if (!done) failed() })
|
|
368
|
+
}
|
|
369
|
+
return result
|
|
370
|
+
} catch {
|
|
371
|
+
if (!done) failed()
|
|
372
|
+
return undefined
|
|
237
373
|
}
|
|
238
|
-
if (result && typeof result.then === 'function') return result.finally(restore)
|
|
239
|
-
restore()
|
|
240
|
-
return result
|
|
241
374
|
}
|
|
242
375
|
|
|
243
376
|
function guardedRoute(route, getConnection) {
|