dsh-vision-router 2.3.0 → 3.0.0
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 +12 -3
- package/README.zh.md +12 -3
- package/docs/architecture/3.0-architecture-plan.md +252 -0
- package/docs/architecture/3.0-r0-contract-ledger.md +137 -0
- package/docs/architecture/3.0-r1-source-artifact-boundary.md +98 -0
- package/docs/architecture/3.0-r10-final-correctness-closure.md +207 -0
- package/docs/architecture/3.0-r11-adversarial-review.md +441 -0
- package/docs/architecture/3.0-r5-browser-go-no-go.md +165 -0
- package/docs/architecture/3.0-r5-browser-typescript-gate.md +105 -0
- package/docs/architecture/3.0-r6-compat-retirement.md +203 -0
- package/docs/architecture/3.0-r7-public-api.md +151 -0
- package/docs/architecture/3.0-r8-ci-release.md +248 -0
- package/docs/architecture/3.0-r9-final-audit.md +370 -0
- package/docs/architecture/dsh-support-window.md +5 -3
- package/docs/releases/v3.0.0.md +85 -0
- package/entry.js +1 -1
- package/index.js +108 -71
- package/lib/agent-request-route-authority.js +1 -20
- package/lib/core-apply-lifecycle.js +37 -43
- package/lib/core-cache.js +87 -0
- package/lib/core-primitives.js +3 -143
- package/lib/core-vision-surface-domain.js +63 -0
- package/lib/core-vision-surface.js +11 -77
- package/lib/delegated-call-config.js +15 -24
- package/lib/doctor-cli.js +0 -0
- package/lib/doctor-runtime.js +4 -1
- package/lib/dsh-contract-compat.js +26 -230
- package/lib/dsh-host-capabilities.js +3 -9
- package/lib/dsh-settings-017-compat.js +17 -10
- package/lib/file-logger.js +8 -3
- package/lib/grounding-coordinate-runtime.js +1 -2
- package/lib/http-body-limit.js +97 -79
- package/lib/image-offload-compat.js +30 -21
- package/lib/image-resource-governor.js +210 -228
- package/lib/legacy-global-proxy-boundary.js +46 -3
- package/lib/live-model-client-prelude.js +12 -4
- package/lib/live-model-discovery.js +134 -43
- package/lib/local-vision-stabilizer.js +31 -17
- package/lib/maintenance-routes.js +1 -3
- package/lib/native-image-coexistence.js +15 -3
- package/lib/ollama-cold-start.js +87 -23
- package/lib/pi-ai-bridge-wire-compat.js +3 -2
- package/lib/proxy-dispatcher-pool.js +151 -92
- package/lib/proxy-routing.js +75 -51
- package/lib/proxy-url-compat.js +3 -10
- package/lib/public-config.js +23 -44
- package/lib/public-entry.d.ts +148 -0
- package/lib/public-entry.js +17 -4
- package/lib/replay-delegation.js +12 -7
- package/lib/replay-envelope-v2-compat.js +12 -7
- package/lib/runtime-composition.js +22 -4
- package/lib/screenshot-source-boundary.js +2 -2
- package/lib/session-affinity-runtime.js +113 -97
- package/lib/session-affinity.js +89 -76
- package/lib/session-event-history-compat.js +30 -0
- package/lib/session-query-readers.js +263 -0
- package/lib/session-surface-compat.js +30 -31
- package/lib/session-surface-policy-domain.js +85 -0
- package/lib/session-surface-policy.js +2 -111
- package/lib/session-turn-resolver.js +2 -6
- package/lib/session-vision-attachment-index.js +143 -0
- package/lib/session-vision-delegation-boundary.js +34 -0
- package/lib/session-vision-delegation.js +22 -0
- package/lib/session-vision-index.js +25 -107
- package/lib/session-vision-mode-authority.js +229 -192
- package/lib/session-vision-mode-boundary.js +65 -22
- package/lib/session-vision-policy-domain.js +304 -0
- package/lib/session-vision-policy.js +104 -0
- package/lib/session-vision-state.js +368 -378
- package/lib/settings-client-017-compat.js +9 -7
- package/lib/structured-flow-hardening.js +22 -4
- package/lib/tesseract-exec-compat.js +9 -1
- package/lib/turn-budget-context.js +47 -41
- package/lib/update-check.js +11 -2
- package/lib/vision-attachment-handle-runtime.js +176 -156
- package/lib/vision-backend-runtime-policy.js +16 -0
- package/lib/vision-background-benchmark.js +41 -4
- package/lib/vision-background-failure-policy.js +41 -41
- package/lib/vision-background-stop-store-core.js +217 -0
- package/lib/vision-background-stop-store.js +8 -179
- package/lib/vision-capability-benchmark.js +11 -61
- package/lib/vision-capability-evidence.js +35 -0
- package/lib/vision-capability-fingerprint.js +72 -0
- package/lib/vision-capability-identity.js +116 -61
- package/lib/vision-capability-router.js +13 -33
- package/lib/vision-chunk-assembler.js +95 -0
- package/lib/vision-diagnostics-routes.js +34 -4
- package/lib/vision-execution-order-apply.js +45 -55
- package/lib/vision-execution-order.js +19 -19
- package/lib/vision-execution-policy.js +12 -7
- package/lib/vision-model-registry.js +30 -17
- package/lib/vision-provider-transport.js +137 -147
- package/lib/vision-routing-authority.js +47 -48
- package/lib/vision-routing-product.js +26 -29
- package/lib/vision-routing-settings-prelude.js +8 -3
- package/lib/vision-runtime-performance-store.js +166 -0
- package/lib/vision-runtime-performance.js +13 -142
- package/lib/vision-tool-runtime-boundary.js +99 -30
- package/lib/web/dsh-settings-017-root-transport.js +27 -12
- package/lib/web/remote-settings-client.js +4 -7
- package/lib/web-capability-boundary.js +17 -7
- package/lib/wrapper-directory.js +14 -23
- package/package.json +37 -11
|
@@ -552,6 +552,7 @@ export function createBackgroundCapabilityProfiler({
|
|
|
552
552
|
let lastForegroundAt = Number(now())
|
|
553
553
|
let runningController
|
|
554
554
|
let runningWork
|
|
555
|
+
let tickTask
|
|
555
556
|
let topologyDirty = false
|
|
556
557
|
let lastAuthority = resolveVisionRoutingAuthority(activeSettings(ctx, config))
|
|
557
558
|
const backoff = new Map()
|
|
@@ -689,7 +690,7 @@ export function createBackgroundCapabilityProfiler({
|
|
|
689
690
|
}
|
|
690
691
|
}
|
|
691
692
|
|
|
692
|
-
const
|
|
693
|
+
const runTick = async () => {
|
|
693
694
|
if (stopped || runningController) return
|
|
694
695
|
const currentNow = Number(now())
|
|
695
696
|
const current = activeSettings(ctx, config)
|
|
@@ -864,6 +865,32 @@ export function createBackgroundCapabilityProfiler({
|
|
|
864
865
|
}
|
|
865
866
|
}
|
|
866
867
|
|
|
868
|
+
const tick = () => {
|
|
869
|
+
if (tickTask) return tickTask
|
|
870
|
+
let tracked
|
|
871
|
+
// `tracked` is assigned before any rejection can be observed: the timer
|
|
872
|
+
// callback and the lifecycle disposer both call tick(), and a background
|
|
873
|
+
// scan that throws must degrade to a logged failure instead of an
|
|
874
|
+
// unhandled process-level rejection.
|
|
875
|
+
tracked = Promise.resolve()
|
|
876
|
+
.then(runTick)
|
|
877
|
+
.catch((error) => {
|
|
878
|
+
try {
|
|
879
|
+
logger?.warn?.(
|
|
880
|
+
'vision-router: background benchmark tick failed: %s',
|
|
881
|
+
bounded(error?.message ?? error),
|
|
882
|
+
)
|
|
883
|
+
} catch {
|
|
884
|
+
/* diagnostics only */
|
|
885
|
+
}
|
|
886
|
+
})
|
|
887
|
+
.finally(() => {
|
|
888
|
+
if (tickTask === tracked) tickTask = undefined
|
|
889
|
+
})
|
|
890
|
+
tickTask = tracked
|
|
891
|
+
return tracked
|
|
892
|
+
}
|
|
893
|
+
|
|
867
894
|
const foregroundStart = () => {
|
|
868
895
|
activeForeground += 1
|
|
869
896
|
lastForegroundAt = Number(now())
|
|
@@ -893,6 +920,7 @@ export function createBackgroundCapabilityProfiler({
|
|
|
893
920
|
if (timer !== undefined) clearTimer(timer)
|
|
894
921
|
timer = undefined
|
|
895
922
|
yieldBackground('background profiler stopped')
|
|
923
|
+
return tickTask ?? Promise.resolve()
|
|
896
924
|
}
|
|
897
925
|
|
|
898
926
|
schedule(idleMs)
|
|
@@ -1022,17 +1050,26 @@ export function installBackgroundCapabilityProfiling(ctx, config, core, store, o
|
|
|
1022
1050
|
// Event-driven wakeups are an optimization; the short authority scan remains.
|
|
1023
1051
|
}
|
|
1024
1052
|
|
|
1053
|
+
let lifecycleOwned = true
|
|
1025
1054
|
try {
|
|
1026
|
-
ctx?.effect?.(() => () => {
|
|
1055
|
+
ctx?.effect?.(() => async () => {
|
|
1027
1056
|
for (const dispose of eventDisposers.splice(0)) {
|
|
1028
1057
|
try { dispose() } catch {}
|
|
1029
1058
|
}
|
|
1030
|
-
profiler.stop()
|
|
1059
|
+
await profiler.stop()
|
|
1031
1060
|
}, 'vision-router: background capability profiler')
|
|
1032
1061
|
} catch {
|
|
1033
|
-
|
|
1062
|
+
lifecycleOwned = false
|
|
1063
|
+
for (const dispose of eventDisposers.splice(0)) {
|
|
1064
|
+
try { dispose() } catch {}
|
|
1065
|
+
}
|
|
1066
|
+
void profiler.stop().catch(() => {})
|
|
1034
1067
|
}
|
|
1035
1068
|
|
|
1069
|
+
// An inactive/unowned generation must not continue admitting client scripts,
|
|
1070
|
+
// routes, or tool wrappers after its profiler ownership was rejected.
|
|
1071
|
+
if (!lifecycleOwned) return { ctx, profiler }
|
|
1072
|
+
|
|
1036
1073
|
installVisionExactCheckClient(ctx)
|
|
1037
1074
|
|
|
1038
1075
|
try {
|
|
@@ -1,47 +1,47 @@
|
|
|
1
|
-
export const BACKGROUND_AUTH_STOP_TTL_MS = 10 * 60 * 1000
|
|
2
|
-
export const BACKGROUND_ENDPOINT_STOP_TTL_MS = 6 * 60 * 60 * 1000
|
|
3
|
-
export const BACKGROUND_TRANSIENT_RETRY_MS = 30 * 60 * 1000
|
|
4
|
-
|
|
1
|
+
export const BACKGROUND_AUTH_STOP_TTL_MS = 10 * 60 * 1000;
|
|
2
|
+
export const BACKGROUND_ENDPOINT_STOP_TTL_MS = 6 * 60 * 60 * 1000;
|
|
3
|
+
export const BACKGROUND_TRANSIENT_RETRY_MS = 30 * 60 * 1000;
|
|
5
4
|
const DEFAULT_POLICY = Object.freeze({
|
|
6
|
-
retryable: true,
|
|
7
|
-
persist: false,
|
|
8
|
-
retryAfterMs: undefined,
|
|
9
|
-
ttlMs: undefined,
|
|
10
|
-
})
|
|
11
|
-
|
|
12
|
-
const POLICIES = Object.freeze({
|
|
13
|
-
auth: Object.freeze({
|
|
14
|
-
retryable: false,
|
|
15
|
-
persist: false,
|
|
16
|
-
ttlMs: BACKGROUND_AUTH_STOP_TTL_MS,
|
|
17
|
-
}),
|
|
18
|
-
protocol: Object.freeze({
|
|
19
|
-
retryable: false,
|
|
20
|
-
persist: true,
|
|
21
|
-
ttlMs: BACKGROUND_ENDPOINT_STOP_TTL_MS,
|
|
22
|
-
}),
|
|
23
|
-
unavailable: Object.freeze({
|
|
24
|
-
retryable: false,
|
|
25
|
-
persist: true,
|
|
26
|
-
ttlMs: BACKGROUND_ENDPOINT_STOP_TTL_MS,
|
|
27
|
-
}),
|
|
28
|
-
'unsupported-image': Object.freeze({
|
|
29
|
-
retryable: false,
|
|
30
|
-
persist: false,
|
|
31
|
-
}),
|
|
32
|
-
'visual-proof': Object.freeze({
|
|
33
5
|
retryable: true,
|
|
34
6
|
persist: false,
|
|
35
|
-
retryAfterMs:
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
7
|
+
retryAfterMs: undefined,
|
|
8
|
+
ttlMs: undefined,
|
|
9
|
+
});
|
|
10
|
+
const POLICIES = Object.freeze({
|
|
11
|
+
auth: Object.freeze({
|
|
12
|
+
retryable: false,
|
|
13
|
+
persist: false,
|
|
14
|
+
ttlMs: BACKGROUND_AUTH_STOP_TTL_MS,
|
|
15
|
+
}),
|
|
16
|
+
protocol: Object.freeze({
|
|
17
|
+
retryable: false,
|
|
18
|
+
persist: true,
|
|
19
|
+
ttlMs: BACKGROUND_ENDPOINT_STOP_TTL_MS,
|
|
20
|
+
}),
|
|
21
|
+
unavailable: Object.freeze({
|
|
22
|
+
retryable: false,
|
|
23
|
+
persist: true,
|
|
24
|
+
ttlMs: BACKGROUND_ENDPOINT_STOP_TTL_MS,
|
|
25
|
+
}),
|
|
26
|
+
'unsupported-image': Object.freeze({
|
|
27
|
+
retryable: false,
|
|
28
|
+
persist: false,
|
|
29
|
+
}),
|
|
30
|
+
'visual-proof': Object.freeze({
|
|
31
|
+
retryable: true,
|
|
32
|
+
persist: false,
|
|
33
|
+
retryAfterMs: BACKGROUND_TRANSIENT_RETRY_MS,
|
|
34
|
+
}),
|
|
35
|
+
infrastructure: Object.freeze({
|
|
36
|
+
retryable: true,
|
|
37
|
+
persist: false,
|
|
38
|
+
retryAfterMs: BACKGROUND_TRANSIENT_RETRY_MS,
|
|
39
|
+
}),
|
|
40
|
+
});
|
|
44
41
|
/** Failure lifetime is separate from failure classification. */
|
|
45
42
|
export function backgroundFailurePolicy(errorClass) {
|
|
46
|
-
|
|
43
|
+
const key = String(errorClass ?? '');
|
|
44
|
+
return Object.prototype.hasOwnProperty.call(POLICIES, key)
|
|
45
|
+
? POLICIES[key]
|
|
46
|
+
: DEFAULT_POLICY;
|
|
47
47
|
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { BENCHMARK_AXES, } from './vision-capability-evidence.js';
|
|
6
|
+
import { CAPABILITY_BENCHMARK_SUITE_REVISION } from './vision-capability-fingerprint.js';
|
|
7
|
+
const CACHE_VERSION = 3;
|
|
8
|
+
const MAX_ENTRIES = 256;
|
|
9
|
+
function propertyBag(value) {
|
|
10
|
+
return value !== null && typeof value === 'object'
|
|
11
|
+
? value
|
|
12
|
+
: undefined;
|
|
13
|
+
}
|
|
14
|
+
function cleanText(value, max = 256) {
|
|
15
|
+
return typeof value === 'string' && value.trim() !== ''
|
|
16
|
+
? value.trim().slice(0, max)
|
|
17
|
+
: '';
|
|
18
|
+
}
|
|
19
|
+
function benchmarkAxis(value) {
|
|
20
|
+
return BENCHMARK_AXES.includes(value)
|
|
21
|
+
? value
|
|
22
|
+
: undefined;
|
|
23
|
+
}
|
|
24
|
+
function persistentFailureClass(value) {
|
|
25
|
+
return value === 'unavailable' || value === 'protocol'
|
|
26
|
+
? value
|
|
27
|
+
: undefined;
|
|
28
|
+
}
|
|
29
|
+
function cleanStop(value) {
|
|
30
|
+
const record = propertyBag(value);
|
|
31
|
+
if (record === undefined)
|
|
32
|
+
return undefined;
|
|
33
|
+
if (Number(record.suiteRevision) !== CAPABILITY_BENCHMARK_SUITE_REVISION) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
const fingerprint = cleanText(record.fingerprint, 64);
|
|
37
|
+
if (!/^ep2_[0-9a-f]{32}$/.test(fingerprint))
|
|
38
|
+
return undefined;
|
|
39
|
+
const key = cleanText(record.key);
|
|
40
|
+
const provider = cleanText(record.provider);
|
|
41
|
+
const model = cleanText(record.model);
|
|
42
|
+
const axis = benchmarkAxis(cleanText(record.axis, 32));
|
|
43
|
+
const errorClass = persistentFailureClass(cleanText(record.errorClass, 48));
|
|
44
|
+
const errorCode = cleanText(record.errorCode, 96);
|
|
45
|
+
const recordedAt = Number(record.recordedAt);
|
|
46
|
+
const expiresAt = Number(record.expiresAt);
|
|
47
|
+
if (!key || !provider || !model || axis === undefined || errorClass === undefined) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
if (!Number.isFinite(recordedAt) || recordedAt <= 0)
|
|
51
|
+
return undefined;
|
|
52
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= recordedAt)
|
|
53
|
+
return undefined;
|
|
54
|
+
return {
|
|
55
|
+
fingerprint,
|
|
56
|
+
key,
|
|
57
|
+
provider,
|
|
58
|
+
model,
|
|
59
|
+
axis,
|
|
60
|
+
errorClass,
|
|
61
|
+
...(errorCode ? { errorCode } : {}),
|
|
62
|
+
recordedAt,
|
|
63
|
+
expiresAt,
|
|
64
|
+
suiteRevision: CAPABILITY_BENCHMARK_SUITE_REVISION,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function recordKey(value) {
|
|
68
|
+
return `${value.fingerprint}\u0000${value.axis}`;
|
|
69
|
+
}
|
|
70
|
+
function selectRetainedStops(values, at, maxEntries = MAX_ENTRIES) {
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
return [...values]
|
|
73
|
+
.map(cleanStop)
|
|
74
|
+
.filter((item) => item !== undefined && item.expiresAt > at)
|
|
75
|
+
.sort((a, b) => b.recordedAt - a.recordedAt
|
|
76
|
+
|| recordKey(a).localeCompare(recordKey(b)))
|
|
77
|
+
.filter((item) => {
|
|
78
|
+
const key = recordKey(item);
|
|
79
|
+
if (seen.has(key))
|
|
80
|
+
return false;
|
|
81
|
+
seen.add(key);
|
|
82
|
+
return true;
|
|
83
|
+
})
|
|
84
|
+
.slice(0, maxEntries);
|
|
85
|
+
}
|
|
86
|
+
function retainedStopMap(values, at, maxEntries = MAX_ENTRIES) {
|
|
87
|
+
return new Map(selectRetainedStops(values, at, maxEntries)
|
|
88
|
+
.map((item) => [recordKey(item), item]));
|
|
89
|
+
}
|
|
90
|
+
async function save(file, records, fsOps, at) {
|
|
91
|
+
const temporary = `${file}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
|
92
|
+
const stops = selectRetainedStops(records.values(), at);
|
|
93
|
+
await fsOps.mkdir(path.dirname(file), { recursive: true });
|
|
94
|
+
await fsOps.writeFile(temporary, JSON.stringify({ version: CACHE_VERSION, stops }), { encoding: 'utf8', mode: 0o600 });
|
|
95
|
+
await fsOps.rename(temporary, file);
|
|
96
|
+
}
|
|
97
|
+
async function load(file, fsOps, at) {
|
|
98
|
+
try {
|
|
99
|
+
const raw = await fsOps.readFile(file, 'utf8');
|
|
100
|
+
const body = propertyBag(JSON.parse(raw));
|
|
101
|
+
const stops = body?.stops;
|
|
102
|
+
const version = Number(body?.version);
|
|
103
|
+
if (!Array.isArray(stops)
|
|
104
|
+
|| (version !== 2 && version !== CACHE_VERSION)) {
|
|
105
|
+
return new Map();
|
|
106
|
+
}
|
|
107
|
+
const records = retainedStopMap(stops, at);
|
|
108
|
+
if (version === 2)
|
|
109
|
+
await save(file, records, fsOps, at);
|
|
110
|
+
return records;
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (propertyBag(error)?.code === 'ENOENT')
|
|
114
|
+
return new Map();
|
|
115
|
+
return new Map();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Durable stop owner for unattended capability measurement.
|
|
120
|
+
*
|
|
121
|
+
* Only endpoint/model evidence that is benchmark-axis-scoped and safe to
|
|
122
|
+
* persist (protocol or unavailable) can enter this store. AUTH remains a
|
|
123
|
+
* process-local access condition and is rejected by runtime validation even
|
|
124
|
+
* when legacy v2 cache data contains it.
|
|
125
|
+
*/
|
|
126
|
+
export function createBackgroundBenchmarkStopStoreCore(options) {
|
|
127
|
+
const file = options.file;
|
|
128
|
+
const fsOps = {
|
|
129
|
+
readFile: options.fsOps?.readFile ?? readFile,
|
|
130
|
+
mkdir: options.fsOps?.mkdir ?? mkdir,
|
|
131
|
+
writeFile: options.fsOps?.writeFile ?? writeFile,
|
|
132
|
+
rename: options.fsOps?.rename ?? rename,
|
|
133
|
+
};
|
|
134
|
+
const logger = options.logger;
|
|
135
|
+
const now = typeof options.now === 'function' ? options.now : Date.now;
|
|
136
|
+
let records = new Map();
|
|
137
|
+
let saveTail = Promise.resolve();
|
|
138
|
+
const ready = load(file, fsOps, Number(now()))
|
|
139
|
+
.then((loaded) => {
|
|
140
|
+
records = loaded;
|
|
141
|
+
});
|
|
142
|
+
const persist = () => {
|
|
143
|
+
const at = Number(now());
|
|
144
|
+
saveTail = saveTail
|
|
145
|
+
.then(() => save(file, records, fsOps, at))
|
|
146
|
+
.catch((error) => {
|
|
147
|
+
logger?.warn?.('vision-router: background stop cache write failed: %s', cleanText(propertyBag(error)?.message ?? error, 240));
|
|
148
|
+
});
|
|
149
|
+
return saveTail;
|
|
150
|
+
};
|
|
151
|
+
const reconcile = () => {
|
|
152
|
+
const at = Number(now());
|
|
153
|
+
const retained = retainedStopMap(records.values(), at);
|
|
154
|
+
const changed = retained.size !== records.size
|
|
155
|
+
|| [...retained.keys()].some((key) => !records.has(key));
|
|
156
|
+
records = retained;
|
|
157
|
+
return changed;
|
|
158
|
+
};
|
|
159
|
+
return {
|
|
160
|
+
file,
|
|
161
|
+
async list() {
|
|
162
|
+
await ready;
|
|
163
|
+
if (reconcile())
|
|
164
|
+
await persist();
|
|
165
|
+
return [...records.values()]
|
|
166
|
+
.sort((a, b) => b.recordedAt - a.recordedAt);
|
|
167
|
+
},
|
|
168
|
+
async mark({ fingerprint, key, provider, model, axis, errorClass, errorCode, recordedAt = Number(now()), expiresAt, } = {}) {
|
|
169
|
+
await ready;
|
|
170
|
+
reconcile();
|
|
171
|
+
const clean = cleanStop({
|
|
172
|
+
fingerprint,
|
|
173
|
+
key,
|
|
174
|
+
provider,
|
|
175
|
+
model,
|
|
176
|
+
axis,
|
|
177
|
+
errorClass,
|
|
178
|
+
errorCode,
|
|
179
|
+
recordedAt,
|
|
180
|
+
expiresAt,
|
|
181
|
+
suiteRevision: CAPABILITY_BENCHMARK_SUITE_REVISION,
|
|
182
|
+
});
|
|
183
|
+
if (clean === undefined)
|
|
184
|
+
return undefined;
|
|
185
|
+
const keyOfStop = recordKey(clean);
|
|
186
|
+
records.set(keyOfStop, clean);
|
|
187
|
+
reconcile();
|
|
188
|
+
await persist();
|
|
189
|
+
return records.get(keyOfStop);
|
|
190
|
+
},
|
|
191
|
+
async clearStop(fingerprint, axis) {
|
|
192
|
+
await ready;
|
|
193
|
+
const removed = records.delete(`${String(fingerprint ?? '')}\u0000${String(axis ?? '')}`);
|
|
194
|
+
if (removed)
|
|
195
|
+
await persist();
|
|
196
|
+
return removed;
|
|
197
|
+
},
|
|
198
|
+
async clearFingerprint(fingerprint) {
|
|
199
|
+
await ready;
|
|
200
|
+
const wanted = String(fingerprint ?? '');
|
|
201
|
+
let removed = false;
|
|
202
|
+
for (const [key, record] of records.entries()) {
|
|
203
|
+
if (record.fingerprint !== wanted)
|
|
204
|
+
continue;
|
|
205
|
+
records.delete(key);
|
|
206
|
+
removed = true;
|
|
207
|
+
}
|
|
208
|
+
if (removed)
|
|
209
|
+
await persist();
|
|
210
|
+
return removed;
|
|
211
|
+
},
|
|
212
|
+
async flush() {
|
|
213
|
+
await ready;
|
|
214
|
+
await saveTail;
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
@@ -1,190 +1,19 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto'
|
|
2
|
-
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
3
1
|
import path from 'node:path'
|
|
4
|
-
import process from 'node:process'
|
|
5
2
|
import { resolveDshHome } from './doctor.js'
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
// v1 stops had no expiry; v2 AUTH stops also persisted a secret-derived
|
|
9
|
-
// credential fingerprint. Do not migrate either format: v3 persists only
|
|
10
|
-
// non-secret endpoint/model stop evidence.
|
|
11
|
-
const CACHE_VERSION = 3
|
|
12
|
-
const MAX_ENTRIES = 256
|
|
13
|
-
const AXES = new Set(['ocr', 'general', 'document', 'structured', 'grounding'])
|
|
14
|
-
const CLASSES = new Set(['unavailable', 'protocol'])
|
|
15
|
-
|
|
16
|
-
function cleanText(value, max = 256) {
|
|
17
|
-
return typeof value === 'string' && value.trim() !== '' ? value.trim().slice(0, max) : ''
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function cleanStop(value) {
|
|
21
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
|
22
|
-
if (Number(value.suiteRevision) !== CAPABILITY_BENCHMARK_SUITE_REVISION) return undefined
|
|
23
|
-
const fingerprint = cleanText(value.fingerprint, 64)
|
|
24
|
-
if (!/^ep2_[0-9a-f]{32}$/.test(fingerprint)) return undefined
|
|
25
|
-
const key = cleanText(value.key)
|
|
26
|
-
const provider = cleanText(value.provider)
|
|
27
|
-
const model = cleanText(value.model)
|
|
28
|
-
const axis = cleanText(value.axis, 32)
|
|
29
|
-
const errorClass = cleanText(value.errorClass, 48)
|
|
30
|
-
const errorCode = cleanText(value.errorCode, 96)
|
|
31
|
-
const recordedAt = Number(value.recordedAt)
|
|
32
|
-
const expiresAt = Number(value.expiresAt)
|
|
33
|
-
if (!key || !provider || !model || !AXES.has(axis) || !CLASSES.has(errorClass)) return undefined
|
|
34
|
-
if (!Number.isFinite(recordedAt) || recordedAt <= 0) return undefined
|
|
35
|
-
if (!Number.isFinite(expiresAt) || expiresAt <= recordedAt) return undefined
|
|
36
|
-
return {
|
|
37
|
-
fingerprint,
|
|
38
|
-
key,
|
|
39
|
-
provider,
|
|
40
|
-
model,
|
|
41
|
-
axis,
|
|
42
|
-
errorClass,
|
|
43
|
-
...(errorCode ? { errorCode } : {}),
|
|
44
|
-
recordedAt,
|
|
45
|
-
expiresAt,
|
|
46
|
-
suiteRevision: CAPABILITY_BENCHMARK_SUITE_REVISION,
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function recordKey(value) {
|
|
51
|
-
return `${value.fingerprint}\u0000${value.axis}`
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function selectRetainedStops(values, at, maxEntries = MAX_ENTRIES) {
|
|
55
|
-
const seen = new Set()
|
|
56
|
-
return [...values]
|
|
57
|
-
.map(cleanStop)
|
|
58
|
-
.filter((item) => item && item.expiresAt > at)
|
|
59
|
-
.sort((a, b) => b.recordedAt - a.recordedAt || recordKey(a).localeCompare(recordKey(b)))
|
|
60
|
-
.filter((item) => {
|
|
61
|
-
const key = recordKey(item)
|
|
62
|
-
if (seen.has(key)) return false
|
|
63
|
-
seen.add(key)
|
|
64
|
-
return true
|
|
65
|
-
})
|
|
66
|
-
.slice(0, maxEntries)
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function retainedStopMap(values, at, maxEntries = MAX_ENTRIES) {
|
|
70
|
-
return new Map(selectRetainedStops(values, at, maxEntries).map((item) => [recordKey(item), item]))
|
|
71
|
-
}
|
|
3
|
+
import { createBackgroundBenchmarkStopStoreCore } from './vision-background-stop-store-core.js'
|
|
72
4
|
|
|
73
5
|
export function backgroundBenchmarkStopCachePath(dshHome = resolveDshHome()) {
|
|
74
6
|
return path.join(dshHome, 'cache', 'vision-router', 'background-benchmark-stops.json')
|
|
75
7
|
}
|
|
76
8
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (!body || !Array.isArray(body.stops) || ![2, CACHE_VERSION].includes(body.version)) return new Map()
|
|
82
|
-
const records = retainedStopMap(body.stops, at)
|
|
83
|
-
if (body.version === 2) await save(file, records, fsOps, at)
|
|
84
|
-
return records
|
|
85
|
-
} catch (error) {
|
|
86
|
-
if (error?.code === 'ENOENT') return new Map()
|
|
87
|
-
return new Map()
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
async function save(file, records, fsOps, at) {
|
|
92
|
-
const temporary = `${file}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`
|
|
93
|
-
const stops = selectRetainedStops(records.values(), at)
|
|
94
|
-
await fsOps.mkdir(path.dirname(file), { recursive: true })
|
|
95
|
-
await fsOps.writeFile(temporary, JSON.stringify({ version: CACHE_VERSION, stops }), { encoding: 'utf8', mode: 0o600 })
|
|
96
|
-
await fsOps.rename(temporary, file)
|
|
97
|
-
}
|
|
98
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Keep DSH-home discovery at the dynamic Host/path boundary. The typed core
|
|
11
|
+
* owns durable stop validation, migration, retention and persistence.
|
|
12
|
+
*/
|
|
99
13
|
export function createBackgroundBenchmarkStopStore(options = {}) {
|
|
100
14
|
const file = options.cacheFile ?? backgroundBenchmarkStopCachePath(options.dshHome)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
mkdir: options.fsOps?.mkdir ?? mkdir,
|
|
104
|
-
writeFile: options.fsOps?.writeFile ?? writeFile,
|
|
105
|
-
rename: options.fsOps?.rename ?? rename,
|
|
106
|
-
}
|
|
107
|
-
const logger = options.logger
|
|
108
|
-
const now = typeof options.now === 'function' ? options.now : Date.now
|
|
109
|
-
let records = new Map()
|
|
110
|
-
let saveTail = Promise.resolve()
|
|
111
|
-
const ready = load(file, fsOps, Number(now())).then((loaded) => { records = loaded })
|
|
112
|
-
|
|
113
|
-
const persist = () => {
|
|
114
|
-
const at = Number(now())
|
|
115
|
-
saveTail = saveTail
|
|
116
|
-
.then(() => save(file, records, fsOps, at))
|
|
117
|
-
.catch((error) => logger?.warn?.('vision-router: background stop cache write failed: %s', cleanText(error?.message ?? error, 240)))
|
|
118
|
-
return saveTail
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const reconcile = () => {
|
|
122
|
-
const at = Number(now())
|
|
123
|
-
const retained = retainedStopMap(records.values(), at)
|
|
124
|
-
const changed = retained.size !== records.size || [...retained.keys()].some((key) => !records.has(key))
|
|
125
|
-
records = retained
|
|
126
|
-
return changed
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
return {
|
|
15
|
+
return createBackgroundBenchmarkStopStoreCore({
|
|
16
|
+
...options,
|
|
130
17
|
file,
|
|
131
|
-
|
|
132
|
-
await ready
|
|
133
|
-
if (reconcile()) await persist()
|
|
134
|
-
return [...records.values()].sort((a, b) => b.recordedAt - a.recordedAt)
|
|
135
|
-
},
|
|
136
|
-
async mark({
|
|
137
|
-
fingerprint,
|
|
138
|
-
key,
|
|
139
|
-
provider,
|
|
140
|
-
model,
|
|
141
|
-
axis,
|
|
142
|
-
errorClass,
|
|
143
|
-
errorCode,
|
|
144
|
-
recordedAt = Number(now()),
|
|
145
|
-
expiresAt,
|
|
146
|
-
} = {}) {
|
|
147
|
-
await ready
|
|
148
|
-
reconcile()
|
|
149
|
-
const clean = cleanStop({
|
|
150
|
-
fingerprint,
|
|
151
|
-
key,
|
|
152
|
-
provider,
|
|
153
|
-
model,
|
|
154
|
-
axis,
|
|
155
|
-
errorClass,
|
|
156
|
-
errorCode,
|
|
157
|
-
recordedAt,
|
|
158
|
-
expiresAt,
|
|
159
|
-
suiteRevision: CAPABILITY_BENCHMARK_SUITE_REVISION,
|
|
160
|
-
})
|
|
161
|
-
if (!clean) return undefined
|
|
162
|
-
records.set(recordKey(clean), clean)
|
|
163
|
-
reconcile()
|
|
164
|
-
await persist()
|
|
165
|
-
return records.get(recordKey(clean))
|
|
166
|
-
},
|
|
167
|
-
async clearStop(fingerprint, axis) {
|
|
168
|
-
await ready
|
|
169
|
-
const removed = records.delete(`${String(fingerprint ?? '')}\u0000${String(axis ?? '')}`)
|
|
170
|
-
if (removed) await persist()
|
|
171
|
-
return removed
|
|
172
|
-
},
|
|
173
|
-
async clearFingerprint(fingerprint) {
|
|
174
|
-
await ready
|
|
175
|
-
const wanted = String(fingerprint ?? '')
|
|
176
|
-
let removed = false
|
|
177
|
-
for (const [key, record] of records.entries()) {
|
|
178
|
-
if (record.fingerprint !== wanted) continue
|
|
179
|
-
records.delete(key)
|
|
180
|
-
removed = true
|
|
181
|
-
}
|
|
182
|
-
if (removed) await persist()
|
|
183
|
-
return removed
|
|
184
|
-
},
|
|
185
|
-
async flush() {
|
|
186
|
-
await ready
|
|
187
|
-
await saveTail
|
|
188
|
-
},
|
|
189
|
-
}
|
|
18
|
+
})
|
|
190
19
|
}
|
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto'
|
|
2
|
-
import process from 'node:process'
|
|
3
1
|
import { VISION_INTENTS } from './vision-capability-router.js'
|
|
2
|
+
import {
|
|
3
|
+
CAPABILITY_BENCHMARK_RENDERER_SCOPE,
|
|
4
|
+
CAPABILITY_BENCHMARK_SUITE_REVISION,
|
|
5
|
+
capabilityBenchmarkFingerprint,
|
|
6
|
+
} from './vision-capability-fingerprint.js'
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
CAPABILITY_BENCHMARK_RENDERER_SCOPE,
|
|
10
|
+
CAPABILITY_BENCHMARK_SUITE_REVISION,
|
|
11
|
+
capabilityBenchmarkFingerprint,
|
|
12
|
+
} from './vision-capability-fingerprint.js'
|
|
4
13
|
|
|
5
14
|
const WIDTH = 768
|
|
6
15
|
const HEIGHT = 512
|
|
7
16
|
|
|
8
|
-
export const CAPABILITY_BENCHMARK_SUITE_REVISION = 5
|
|
9
|
-
// Renderer-only fixes must invalidate prior evidence even when the scoring and
|
|
10
|
-
// prompt contract (the suite revision) is unchanged.
|
|
11
|
-
export const CAPABILITY_BENCHMARK_RENDERER_SCOPE = `${process.platform}/${process.arch}-proof-badge-v2`
|
|
12
|
-
|
|
13
17
|
function esc(value) {
|
|
14
18
|
return String(value).replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch])
|
|
15
19
|
}
|
|
@@ -160,60 +164,6 @@ export function listCapabilityBenchmarkFixtures(intents = CORE_BENCHMARK_INTENTS
|
|
|
160
164
|
return fixtures
|
|
161
165
|
}
|
|
162
166
|
|
|
163
|
-
function isSensitiveKey(key) {
|
|
164
|
-
return /(^|[_-])(api[_-]?key|key|token|secret|password|authorization|auth|signature|sig)([_-]|$)/i.test(String(key ?? ''))
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function normalizeEndpoint(value) {
|
|
168
|
-
const raw = String(value ?? '').trim()
|
|
169
|
-
if (raw === '') return ''
|
|
170
|
-
try {
|
|
171
|
-
const url = new URL(raw)
|
|
172
|
-
url.username = ''
|
|
173
|
-
url.password = ''
|
|
174
|
-
url.hash = ''
|
|
175
|
-
for (const key of [...url.searchParams.keys()]) {
|
|
176
|
-
if (isSensitiveKey(key)) url.searchParams.delete(key)
|
|
177
|
-
}
|
|
178
|
-
url.searchParams.sort()
|
|
179
|
-
const pathname = url.pathname === '/' ? '' : url.pathname.replace(/\/+$/, '')
|
|
180
|
-
return `${url.protocol.toLowerCase()}//${url.host.toLowerCase()}${pathname}${url.search}`
|
|
181
|
-
} catch {
|
|
182
|
-
return raw.replace(/\/+$/, '')
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function sanitizeFingerprintValue(value, keyHint = '') {
|
|
187
|
-
if (value === null || value === undefined) return value ?? null
|
|
188
|
-
if (Array.isArray(value)) return value.map((item) => sanitizeFingerprintValue(item))
|
|
189
|
-
if (typeof value === 'object') {
|
|
190
|
-
return Object.fromEntries(
|
|
191
|
-
Object.entries(value)
|
|
192
|
-
.filter(([key]) => !isSensitiveKey(key))
|
|
193
|
-
.sort(([a], [b]) => a.localeCompare(b))
|
|
194
|
-
.map(([key, child]) => [key, sanitizeFingerprintValue(child, key)]),
|
|
195
|
-
)
|
|
196
|
-
}
|
|
197
|
-
if (typeof value === 'string' && /(endpoint|base[_-]?url|url)$/i.test(keyHint)) return normalizeEndpoint(value)
|
|
198
|
-
if (typeof value === 'bigint') return value.toString()
|
|
199
|
-
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') return value
|
|
200
|
-
return String(value)
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
export function capabilityBenchmarkFingerprint({ provider, model, endpoint, config, credentialFingerprint } = {}) {
|
|
204
|
-
const payload = JSON.stringify({
|
|
205
|
-
schema: 'vision-capability-endpoint-v2',
|
|
206
|
-
suiteRevision: CAPABILITY_BENCHMARK_SUITE_REVISION,
|
|
207
|
-
rendererScope: CAPABILITY_BENCHMARK_RENDERER_SCOPE,
|
|
208
|
-
provider: String(provider ?? '').trim(),
|
|
209
|
-
model: String(model ?? '').trim(),
|
|
210
|
-
endpoint: normalizeEndpoint(endpoint),
|
|
211
|
-
config: sanitizeFingerprintValue(config && typeof config === 'object' ? config : null),
|
|
212
|
-
credentialFingerprint: String(credentialFingerprint ?? 'none'),
|
|
213
|
-
})
|
|
214
|
-
return `ep2_${createHash('sha256').update(payload).digest('hex').slice(0, 32)}`
|
|
215
|
-
}
|
|
216
|
-
|
|
217
167
|
function clamp01(value) {
|
|
218
168
|
return Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0))
|
|
219
169
|
}
|