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
|
@@ -73,13 +73,28 @@ plan, revoked Auto authority, or planner failure falls back to the current
|
|
|
73
73
|
configured order. The temporary Auto order is isolated to one visual-tool call
|
|
74
74
|
with `AsyncLocalStorage` and is restored automatically afterward.
|
|
75
75
|
|
|
76
|
-
Host text-only metadata is advisory for a
|
|
77
|
-
not
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
76
|
+
Host text-only metadata is advisory for *selection* of a generative backend,
|
|
77
|
+
not proof of the endpoint's real image capability. It is not, however, advisory
|
|
78
|
+
to DSH's request projection: an explicit text-only declaration causes DSH to
|
|
79
|
+
replace image pixels with text placeholders before the adapter is invoked.
|
|
80
|
+
Vision Router bypasses that projection only for positively identified HTTP OpenAI
|
|
81
|
+
Chat Completions bridges. For Responses API, WebSocket, RPC and other native protocols, a genuinely
|
|
82
|
+
image-capable model must have Host image input declared; otherwise a visual
|
|
83
|
+
request cannot safely reach its registered adapter. Where available, use
|
|
84
|
+
**Settings → Models → provider editor → Customized settings → Model options →
|
|
85
|
+
Input types → Image**, then save and reload the model configuration. This model
|
|
86
|
+
editor control is documented in DSH
|
|
87
|
+
[0.1.7-rc.1](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.1.7-rc.1/packages/client/ui-settings-models/README.md)
|
|
88
|
+
and [0.2.0-rc.2](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.2.0-rc.2/packages/client/ui-settings-models/README.md).
|
|
89
|
+
For older Hosts without this control, set the provider-specific model field:
|
|
90
|
+
`llm-pi-ai` uses `input: [text, image]`, while `llm-deepseek` uses
|
|
91
|
+
`inputModalities: [text, image]`. Only declare image input for endpoints that
|
|
92
|
+
actually accept image content. Unknown modalities still use the registered
|
|
93
|
+
adapter first. **Test Vision** targets the exact dropdown model,
|
|
94
|
+
but a text-only Host declaration without a safe bridge may yield an image-input
|
|
95
|
+
failure rather than a valid visual measurement. Explicitly authorized background
|
|
96
|
+
benchmarking has the same delivery constraint; only successful pixel-grounded
|
|
97
|
+
measurements count as capability evidence for Auto.
|
|
83
98
|
|
|
84
99
|
Structural non-generative exclusions remain hard boundaries: explicit measurement
|
|
85
100
|
authority never turns an endpoint that cannot be invoked as a generative model
|
|
@@ -101,8 +116,9 @@ Auto-routing control.
|
|
|
101
116
|
requests.
|
|
102
117
|
|
|
103
118
|
A Host text-only label is shown as an advisory, not as a separate product mode.
|
|
104
|
-
Quick/Full Benchmark
|
|
105
|
-
|
|
119
|
+
Quick/Full Benchmark prepares generated test images when the user explicitly
|
|
120
|
+
starts the Benchmark, but delivery still depends on the Host's model modalities
|
|
121
|
+
or a verified compatible bridge. The implementation may carry an
|
|
106
122
|
internal force flag for compatibility with older service guards, but users do
|
|
107
123
|
not need to reason about a separate “Force Verify” workflow.
|
|
108
124
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
const wrappedContexts = new WeakMap()
|
|
2
2
|
const wrappedLlmServices = new WeakMap()
|
|
3
|
-
const
|
|
4
|
-
const fullAdapterContractCompat = new WeakMap()
|
|
3
|
+
const adapterContractCompat = Array.from({ length: 4 }, () => new WeakMap())
|
|
5
4
|
|
|
6
5
|
export const DEFAULT_ADAPTER_RECONCILE_MAX_PASSES = 32
|
|
7
6
|
|
|
@@ -37,13 +36,18 @@ function noImageRequestPricing() {
|
|
|
37
36
|
return undefined
|
|
38
37
|
}
|
|
39
38
|
|
|
39
|
+
function normalizedImageRequestTarget(_provider, _model, image) {
|
|
40
|
+
return { width: image.width, height: image.height, maxBytes: image.bytes }
|
|
41
|
+
}
|
|
42
|
+
|
|
40
43
|
/**
|
|
41
44
|
* Normalize Vision Router-owned duck-typed adapters to the Host adapter
|
|
42
45
|
* contracts that have base-class defaults in newer DSH releases.
|
|
43
46
|
*
|
|
44
47
|
* DSH 0.1.1 started dispatching through adapter.prepareCall(). DSH 0.1.2 adds
|
|
45
48
|
* adapter.imageRequestPricing() and the token meter resolves it synchronously
|
|
46
|
-
* on every measurement.
|
|
49
|
+
* on every measurement. DSH 0.2.1 adds adapter.imageRequestTarget() for
|
|
50
|
+
* screenshot consumers, with a base-class identity projection default. Vision Router intentionally registers plain adapter
|
|
47
51
|
* objects, so neither inherited default exists unless we materialize it here.
|
|
48
52
|
*
|
|
49
53
|
* Request-image pricing is deliberately NOT delegated to some other adapter.
|
|
@@ -51,6 +55,9 @@ function noImageRequestPricing() {
|
|
|
51
55
|
* only that exact route can truthfully declare its projection. `undefined` is
|
|
52
56
|
* LlmAdapter's default and keeps the Host's provider-neutral heuristic. An
|
|
53
57
|
* adapter that explicitly implements imageRequestPricing remains authoritative.
|
|
58
|
+
* Image request targets likewise default to the Host's normalized-image
|
|
59
|
+
* identity projection, never a guessed provider-specific resize. An explicit
|
|
60
|
+
* route-owned imageRequestTarget remains authoritative.
|
|
54
61
|
*
|
|
55
62
|
* The compatibility method is installed non-enumerably on extensible adapters
|
|
56
63
|
* to preserve registration identity. Frozen/sealed adapters fall back to one
|
|
@@ -61,17 +68,20 @@ export function ensureAdapterContracts(adapter, options = {}) {
|
|
|
61
68
|
if (!isAdapter(adapter)) return adapter
|
|
62
69
|
|
|
63
70
|
const includeImageRequestPricing = options.imageRequestPricing === true
|
|
71
|
+
const includeImageRequestTarget = options.imageRequestTarget === true
|
|
64
72
|
const needsPrepareCall = !hasFunction(adapter, 'prepareCall')
|
|
65
73
|
const needsImageRequestPricing =
|
|
66
74
|
includeImageRequestPricing && !hasFunction(adapter, 'imageRequestPricing')
|
|
67
|
-
|
|
75
|
+
const needsImageRequestTarget = includeImageRequestTarget && !hasFunction(adapter, 'imageRequestTarget')
|
|
76
|
+
if (!needsPrepareCall && !needsImageRequestPricing && !needsImageRequestTarget) return adapter
|
|
68
77
|
|
|
69
|
-
const cache = includeImageRequestPricing
|
|
78
|
+
const cache = adapterContractCompat[Number(includeImageRequestPricing) + 2 * Number(includeImageRequestTarget)]
|
|
70
79
|
const cached = cache.get(adapter)
|
|
71
80
|
if (cached) return cached
|
|
72
81
|
|
|
73
82
|
const prepareCall = needsPrepareCall ? createPrepareCallFallback(adapter) : undefined
|
|
74
83
|
const imageRequestPricing = needsImageRequestPricing ? noImageRequestPricing : undefined
|
|
84
|
+
const imageRequestTarget = needsImageRequestTarget ? normalizedImageRequestTarget : undefined
|
|
75
85
|
const descriptors = {}
|
|
76
86
|
if (prepareCall !== undefined) {
|
|
77
87
|
descriptors.prepareCall = {
|
|
@@ -87,6 +97,13 @@ export function ensureAdapterContracts(adapter, options = {}) {
|
|
|
87
97
|
value: imageRequestPricing,
|
|
88
98
|
}
|
|
89
99
|
}
|
|
100
|
+
if (imageRequestTarget !== undefined) {
|
|
101
|
+
descriptors.imageRequestTarget = {
|
|
102
|
+
configurable: true,
|
|
103
|
+
writable: true,
|
|
104
|
+
value: imageRequestTarget,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
90
107
|
|
|
91
108
|
// Preserve the exact adapter object whenever possible. Some runtime paths
|
|
92
109
|
// retain registration.adapter and compare/reuse it later, so mutation is less
|
|
@@ -103,6 +120,9 @@ export function ensureAdapterContracts(adapter, options = {}) {
|
|
|
103
120
|
if (property === 'imageRequestPricing' && imageRequestPricing !== undefined) {
|
|
104
121
|
return imageRequestPricing
|
|
105
122
|
}
|
|
123
|
+
if (property === 'imageRequestTarget' && imageRequestTarget !== undefined) {
|
|
124
|
+
return imageRequestTarget
|
|
125
|
+
}
|
|
106
126
|
const value = Reflect.get(target, property, target)
|
|
107
127
|
if (typeof value !== 'function') return value
|
|
108
128
|
let bound = boundFunctions.get(value)
|
|
@@ -131,6 +151,14 @@ function llmSupportsImageRequestPricing(llm) {
|
|
|
131
151
|
}
|
|
132
152
|
}
|
|
133
153
|
|
|
154
|
+
function llmSupportsImageRequestTarget(llm) {
|
|
155
|
+
try {
|
|
156
|
+
return typeof Reflect.get(llm, 'imageRequestTarget', llm) === 'function'
|
|
157
|
+
} catch {
|
|
158
|
+
return false
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
134
162
|
function wrapLlmService(llm) {
|
|
135
163
|
if (!isAdapter(llm)) return llm
|
|
136
164
|
const cached = wrappedLlmServices.get(llm)
|
|
@@ -140,6 +168,7 @@ function wrapLlmService(llm) {
|
|
|
140
168
|
// historical adapter shape, while Hosts exposing the new route-pricing seam
|
|
141
169
|
// receive the base-class-compatible default on our duck-typed adapters.
|
|
142
170
|
const includeImageRequestPricing = llmSupportsImageRequestPricing(llm)
|
|
171
|
+
const includeImageRequestTarget = llmSupportsImageRequestTarget(llm)
|
|
143
172
|
const wrapped = new Proxy(llm, {
|
|
144
173
|
get(target, property) {
|
|
145
174
|
if (property === 'registerAdapter') {
|
|
@@ -149,7 +178,10 @@ function wrapLlmService(llm) {
|
|
|
149
178
|
registerAdapter.call(
|
|
150
179
|
target,
|
|
151
180
|
providers,
|
|
152
|
-
ensureAdapterContracts(adapter, {
|
|
181
|
+
ensureAdapterContracts(adapter, {
|
|
182
|
+
imageRequestPricing: includeImageRequestPricing,
|
|
183
|
+
imageRequestTarget: includeImageRequestTarget,
|
|
184
|
+
}),
|
|
153
185
|
...rest,
|
|
154
186
|
)
|
|
155
187
|
}
|
|
@@ -238,8 +270,9 @@ export function createCoalescingRunner(run, options = {}) {
|
|
|
238
270
|
* their native event semantics.
|
|
239
271
|
*
|
|
240
272
|
* The same deepest registration boundary also normalizes Vision Router-owned
|
|
241
|
-
* adapter contracts: prepareCall for DSH 0.1.1+,
|
|
242
|
-
* the Host exposes the DSH 0.1.2 route-pricing seam
|
|
273
|
+
* adapter contracts: prepareCall for DSH 0.1.1+, imageRequestPricing when
|
|
274
|
+
* the Host exposes the DSH 0.1.2 route-pricing seam, and imageRequestTarget
|
|
275
|
+
* when the Host exposes the DSH 0.2.1 screenshot projection seam. This stays scoped to the
|
|
243
276
|
* wrapped context instead of monkey-patching the Host LLM service process-wide.
|
|
244
277
|
*/
|
|
245
278
|
export function contextWithCoalescedAdapterUpdates(ctx) {
|
|
@@ -996,11 +996,8 @@ export const CLIENT_PRESENTATION_PRELUDE = String.raw`(function(){
|
|
|
996
996
|
return function(){ tag.remove(); };
|
|
997
997
|
}
|
|
998
998
|
|
|
999
|
-
// A presented image
|
|
1000
|
-
//
|
|
1001
|
-
// that call thousands of pixels above them. The card calls this on mount; it only moves the
|
|
1002
|
-
// reader for content that arrived below the viewport — a card that is already visible is left
|
|
1003
|
-
// alone, and so is history above the reader when a session is opened.
|
|
999
|
+
// A fresh presented image below the reader is the same as not being shown. Centre only that
|
|
1000
|
+
// card; visible cards and history on either side of a restored viewport stay untouched.
|
|
1004
1001
|
function focusPresentedCard(node, viewportHeight) {
|
|
1005
1002
|
if (!node || typeof node.getBoundingClientRect !== 'function') return false;
|
|
1006
1003
|
var rect = node.getBoundingClientRect();
|
|
@@ -1022,12 +1019,25 @@ export const CLIENT_PRESENTATION_PRELUDE = String.raw`(function(){
|
|
|
1022
1019
|
return false;
|
|
1023
1020
|
}
|
|
1024
1021
|
|
|
1022
|
+
// New Hosts expose phase; supported DSH 0.1.5 instead marks settled blocks as
|
|
1023
|
+
// 'tool-result'. Normalize the [phase, block kind] tuple without raising the Host floor.
|
|
1024
|
+
function isPresentedResultState(state) {
|
|
1025
|
+
var phase = state && state[0];
|
|
1026
|
+
return phase !== undefined ? phase === 'result' : !!state && state[1] === 'tool-result';
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// Restored history starts settled on both sides of the comparison. Only a live transition into
|
|
1030
|
+
// an image-bearing result is fresh presentation.
|
|
1031
|
+
function shouldFocusPresentedCard(previousState, state, imageCount) {
|
|
1032
|
+
return imageCount > 0 &&
|
|
1033
|
+
isPresentedResultState(state) &&
|
|
1034
|
+
!isPresentedResultState(previousState);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1025
1037
|
try {
|
|
1026
|
-
globalThis.__dvrPresentFocus = function () {
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
var last = cards[cards.length - 1];
|
|
1030
|
-
return last ? focusPresentedCard(last, globalThis.innerHeight || 0) : false;
|
|
1038
|
+
globalThis.__dvrPresentFocus = function (node, previousState, state, imageCount) {
|
|
1039
|
+
return shouldFocusPresentedCard(previousState, state, imageCount) &&
|
|
1040
|
+
focusPresentedCard(node, globalThis.innerHeight || 0);
|
|
1031
1041
|
};
|
|
1032
1042
|
} catch (_) {}
|
|
1033
1043
|
|
|
@@ -1160,6 +1170,13 @@ export const CLIENT_PRESENTATION_PRELUDE = String.raw`(function(){
|
|
|
1160
1170
|
var complete = usable && livePair.mode !== 'unavailable';
|
|
1161
1171
|
var settledMode = React.useRef(null);
|
|
1162
1172
|
var toggleTransition = React.useRef(null);
|
|
1173
|
+
// The Host may render a ready directory while the RPC that selected
|
|
1174
|
+
// it is still in flight. Keep only an in-flight transaction fence here,
|
|
1175
|
+
// never a second source of truth for Vision ON/OFF.
|
|
1176
|
+
var selectionTransaction = React.useRef(null);
|
|
1177
|
+
var refreshSelection = React.useState(0)[1];
|
|
1178
|
+
var selectionInFlight = !!(selectionTransaction.current &&
|
|
1179
|
+
selectionTransaction.current.directory === directory);
|
|
1163
1180
|
if (settledMode.current && settledMode.current.directory !== directory) settledMode.current = null;
|
|
1164
1181
|
if (toggleTransition.current && toggleTransition.current.directory !== directory) toggleTransition.current = null;
|
|
1165
1182
|
if (complete) settledMode.current = { directory: directory, mode: livePair.mode };
|
|
@@ -1184,7 +1201,7 @@ export const CLIENT_PRESENTATION_PRELUDE = String.raw`(function(){
|
|
|
1184
1201
|
var toastState = React.useState(null);
|
|
1185
1202
|
var toast = toastState[0];
|
|
1186
1203
|
var setToast = toastState[1];
|
|
1187
|
-
var busy = state.status === 'selecting';
|
|
1204
|
+
var busy = state.status === 'selecting' || selectionInFlight;
|
|
1188
1205
|
var loading = settling;
|
|
1189
1206
|
var removed = props.session && props.session.removed === true;
|
|
1190
1207
|
var disabled = props.available !== true || removed || busy || loading || livePair.mode === 'unavailable';
|
|
@@ -1226,15 +1243,23 @@ export const CLIENT_PRESENTATION_PRELUDE = String.raw`(function(){
|
|
|
1226
1243
|
'data-active': active ? 'true' : 'false',
|
|
1227
1244
|
'data-dimmed': disabled && !ownSettling && (livePair.mode === 'unavailable' || loading) ? 'true' : 'false',
|
|
1228
1245
|
onClick: function() {
|
|
1229
|
-
if (disabled || !pair.target || typeof props.select !== 'function'
|
|
1246
|
+
if (disabled || !pair.target || typeof props.select !== 'function' ||
|
|
1247
|
+
(selectionTransaction.current && selectionTransaction.current.directory === directory)) return;
|
|
1230
1248
|
var target = pair.target;
|
|
1249
|
+
var ticket = { directory: directory, target: target };
|
|
1250
|
+
selectionTransaction.current = ticket;
|
|
1251
|
+
refreshSelection(function(revision){ return revision + 1; });
|
|
1231
1252
|
toggleTransition.current = {
|
|
1232
1253
|
directory: directory,
|
|
1233
1254
|
target: target,
|
|
1234
1255
|
seenSelecting: false
|
|
1235
1256
|
};
|
|
1236
1257
|
setToast(null);
|
|
1237
|
-
|
|
1258
|
+
var pending;
|
|
1259
|
+
try { pending = props.select(target); }
|
|
1260
|
+
catch (error) { pending = Promise.reject(error); }
|
|
1261
|
+
void Promise.resolve(pending).then(function(accepted){
|
|
1262
|
+
if (selectionTransaction.current !== ticket) return;
|
|
1238
1263
|
if (!accepted) {
|
|
1239
1264
|
toggleTransition.current = null;
|
|
1240
1265
|
announceRejectedSelection();
|
|
@@ -1245,6 +1270,14 @@ export const CLIENT_PRESENTATION_PRELUDE = String.raw`(function(){
|
|
|
1245
1270
|
if (!latest || (latest.status !== 'selecting' && latest.status !== 'idle' && latest.status !== 'loading')) {
|
|
1246
1271
|
toggleTransition.current = null;
|
|
1247
1272
|
}
|
|
1273
|
+
}, function(error){
|
|
1274
|
+
if (selectionTransaction.current !== ticket) return;
|
|
1275
|
+
toggleTransition.current = null;
|
|
1276
|
+
announceRejectedSelection();
|
|
1277
|
+
}).finally(function(){
|
|
1278
|
+
if (selectionTransaction.current !== ticket) return;
|
|
1279
|
+
selectionTransaction.current = null;
|
|
1280
|
+
refreshSelection(function(revision){ return revision + 1; });
|
|
1248
1281
|
});
|
|
1249
1282
|
}
|
|
1250
1283
|
},
|
package/lib/client.js
CHANGED
|
@@ -268,18 +268,18 @@ openLogFolder,
|
|
|
268
268
|
catalogFallback: '),模型字段已退回手动输入。',
|
|
269
269
|
catalogPartialFailure: '部分已配置供应商的模型目录加载失败:{detail}。这些供应商可能仍会显示在「设置 → 模型」中,但暂时不会出现在这里。',
|
|
270
270
|
visionCapsLoading: '正在读取模型图片能力声明(仅用于提示,不影响可选模型)…',
|
|
271
|
-
visionCapsError: '
|
|
272
|
-
visionCapsFiltered: '
|
|
273
|
-
visionCapabilityUndeclaredWarning: '
|
|
274
|
-
visionCapabilityTextOnlyWarning: '
|
|
275
|
-
visionCapabilityUnknownWarning: '
|
|
271
|
+
visionCapsError: '能力数据不可用,仍可选择模型;实际图片投递取决于Host声明及传输支持。',
|
|
272
|
+
visionCapsFiltered: '能力声明不限制后端选择;Host标为仅文本可能导致图片无法送达。',
|
|
273
|
+
visionCapabilityUndeclaredWarning: '图片能力未知:调用时尝试原生adapter,失败后尝试下一后端。',
|
|
274
|
+
visionCapabilityTextOnlyWarning: 'Host 将此模型标为仅文本,adapter 调用前会剥离图片;仅兼容的 HTTP Chat Completions 可桥接。若支持图片,在 DSH「设置 → 模型 → 输入类型」勾选图片;旧 Host 设 llm-pi-ai 的 input: [text, image] 或 llm-deepseek 的 inputModalities: [text, image] 后重载配置,否则跳过此后端。',
|
|
275
|
+
visionCapabilityUnknownWarning: '图片能力待验证;调用失败会尝试下一后端。',
|
|
276
276
|
visionCapsEmptyTitle: '没有可选的用户视觉模型',
|
|
277
277
|
visionCapsEmptyBody: '检测到 {count} 个用户模型,但它们都没有被 DSH 明确标记为支持图片,因此已被安全隐藏。',
|
|
278
278
|
visionCapsHiddenPrefix: '被隐藏的模型:',
|
|
279
279
|
visionCapsReasonMissingImage: '未声明 image',
|
|
280
280
|
visionCapsReasonUnverified: '无法验证图片能力',
|
|
281
281
|
visionCapsHiddenMore: '另有 {count} 个模型未显示',
|
|
282
|
-
visionCapsMissingImageHint: '
|
|
282
|
+
visionCapsMissingImageHint: '若模型确实支持图片,请到 DSH「设置 → 模型 → 输入类型」启用图片并保存;旧 Host 可在 profile 的 cordis.patch.yml 中声明正确的模型输入类型。仅添加额外视觉模型标记不能改变 Host 图片投递。',
|
|
283
283
|
visionCapsRetry: '重新检测模型',
|
|
284
284
|
chainInvalidCurrent: '当前保存的后端已不在可调用模型目录中,或属于非生成式/递归路由,运行时会跳过:',
|
|
285
285
|
retryCatalog: '重试加载目录',
|
|
@@ -301,7 +301,7 @@ openLogFolder,
|
|
|
301
301
|
extraVisionModelsHint:
|
|
302
302
|
'这个设置不再用于“解锁”下拉或允许调用;所有可调用生成式模型本来就能被选择。' +
|
|
303
303
|
'只有当你希望把某个未声明图片能力的模型明确标记为视觉模型时才需要填写。' +
|
|
304
|
-
'
|
|
304
|
+
'此标记不会更改 Host 输入声明。仅经验证的 HTTP Chat Completions 渠道可直接桥接;其他纯文本声明会被安全拒绝。',
|
|
305
305
|
groupRoutes: '路由名称',
|
|
306
306
|
groupProxy: '网络',
|
|
307
307
|
networkAuthorityHint: '默认不接管网络出口:Vision Router 沿用 DSH/Host 当前网络路径。下方代理只用于需要给视觉请求单独指定出口的高级覆盖。',
|
|
@@ -554,18 +554,18 @@ openLogFolder,
|
|
|
554
554
|
catalogFallback: '); model fields fell back to free-text input.',
|
|
555
555
|
catalogPartialFailure: 'Some configured providers failed to load their model catalog: {detail}. They may still appear in Settings → Models, but are temporarily unavailable here.',
|
|
556
556
|
visionCapsLoading: 'Reading model image-capability metadata (advisory only; it does not hide selectable models)…',
|
|
557
|
-
visionCapsError: '
|
|
558
|
-
visionCapsFiltered: '
|
|
559
|
-
visionCapabilityUndeclaredWarning: '
|
|
560
|
-
visionCapabilityTextOnlyWarning: '
|
|
561
|
-
visionCapabilityUnknownWarning: 'Image capability is
|
|
557
|
+
visionCapsError: 'Capabilities unavailable; models remain selectable, but image delivery depends on Host input declaration.',
|
|
558
|
+
visionCapsFiltered: 'Model capability labels do not restrict selection; a Host text-only label may block image delivery.',
|
|
559
|
+
visionCapabilityUndeclaredWarning: 'Image capability unknown; Vision Router tries the adapter and falls through on failure.',
|
|
560
|
+
visionCapabilityTextOnlyWarning: 'Host text-only strips images before adapter; only verified HTTP Chat Completions can bridge. If supported, enable Image in DSH Settings → Models → Input types. Older Hosts: llm-pi-ai input: [text, image]; llm-deepseek inputModalities: [text, image]. Otherwise skip this backend.',
|
|
561
|
+
visionCapabilityUnknownWarning: 'Image capability is checked at call time; failures fall through.',
|
|
562
562
|
visionCapsEmptyTitle: 'No selectable user vision models',
|
|
563
563
|
visionCapsEmptyBody: 'DSH reported {count} user models, but none are explicitly marked as accepting images, so Vision Router hid them safely.',
|
|
564
564
|
visionCapsHiddenPrefix: 'Hidden models:',
|
|
565
565
|
visionCapsReasonMissingImage: 'image input not declared',
|
|
566
566
|
visionCapsReasonUnverified: 'image capability could not be verified',
|
|
567
567
|
visionCapsHiddenMore: '{count} more models not shown',
|
|
568
|
-
visionCapsMissingImageHint: '
|
|
568
|
+
visionCapsMissingImageHint: 'For a model that truly accepts images, enable Image in DSH Settings → Models → Input types. Older Hosts: set the correct input field in your profile cordis.patch.yml. Extra vision labels cannot change Host pixel delivery.',
|
|
569
569
|
visionCapsRetry: 'Re-detect models',
|
|
570
570
|
chainInvalidCurrent: 'This saved backend is no longer callable, or is a non-generative/recursive route, so runtime will skip it: ',
|
|
571
571
|
retryCatalog: 'Retry catalog',
|
|
@@ -587,7 +587,7 @@ openLogFolder,
|
|
|
587
587
|
extraVisionModelsHint:
|
|
588
588
|
'This setting no longer unlocks the picker or admission: every callable generative model is selectable already. ' +
|
|
589
589
|
'Use it only when you want to explicitly label an undeclared model as visual. ' +
|
|
590
|
-
'
|
|
590
|
+
'This label does not change Host input declarations. Verified HTTP Chat Completions can bridge; other explicit text-only routes fail safely.',
|
|
591
591
|
groupRoutes: 'Route names',
|
|
592
592
|
groupProxy: 'Network',
|
|
593
593
|
networkAuthorityHint: 'Vision Router does not own egress by default: it follows the current DSH/Host network path. The proxy below is only an advanced vision-request override.',
|
|
@@ -845,11 +845,12 @@ openLogFolder,
|
|
|
845
845
|
function visionCapabilityWarningKey(capability, status) {
|
|
846
846
|
if (status === 'loading' || status === 'idle') return undefined
|
|
847
847
|
if (status === 'error' || !capability) return 'visionCapabilityUnknownWarning'
|
|
848
|
-
if (capability.attemptable === false
|
|
849
|
-
const
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
848
|
+
if (capability.attemptable === false) return undefined
|
|
849
|
+
const declared = Array.isArray(capability.hostInputModalities)
|
|
850
|
+
? capability.hostInputModalities : capability.inputModalities
|
|
851
|
+
const modalities = Array.isArray(declared) ? declared : []
|
|
852
|
+
if (modalities.length > 0 && !modalities.includes('image')) return 'visionCapabilityTextOnlyWarning'
|
|
853
|
+
return capability.image === true ? undefined : 'visionCapabilityUndeclaredWarning'
|
|
853
854
|
}
|
|
854
855
|
|
|
855
856
|
// ── field specs ──────────────────────────────────────────────────────────
|
|
@@ -4756,12 +4757,11 @@ openLogFolder,
|
|
|
4756
4757
|
}
|
|
4757
4758
|
}, ownerToken)
|
|
4758
4759
|
}
|
|
4759
|
-
//
|
|
4760
|
-
// whenever the user switches the locale in Settings → General.
|
|
4760
|
+
// Keep card copy in sync with the app locale.
|
|
4761
4761
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'vision-router: card locale')
|
|
4762
4762
|
const t = ctx.locale.bind(NS)
|
|
4763
4763
|
const VisionPresentCard = (props) => {
|
|
4764
|
-
const { block, sessionId } = props
|
|
4764
|
+
const { block, sessionId, phase } = props
|
|
4765
4765
|
const content = block && Array.isArray(block.content) ? block.content : []
|
|
4766
4766
|
const images = content
|
|
4767
4767
|
.filter((item) => item && item.type === 'image' && item.attachment)
|
|
@@ -4770,7 +4770,13 @@ openLogFolder,
|
|
|
4770
4770
|
const imageKeySignature = JSON.stringify(imageKeys)
|
|
4771
4771
|
const imageOwnerToken = React.useRef({}).current
|
|
4772
4772
|
const ownedImageKeys = React.useRef(new Set())
|
|
4773
|
-
|
|
4773
|
+
const presentNode = React.useRef()
|
|
4774
|
+
const focusState = React.useRef([phase, block?.kind])
|
|
4775
|
+
React.useEffect(() => {
|
|
4776
|
+
const next = [phase, block?.kind]
|
|
4777
|
+
globalThis.__dvrPresentFocus?.(presentNode.current, focusState.current, next, images.length)
|
|
4778
|
+
focusState.current = next
|
|
4779
|
+
}, [phase, block?.kind, imageKeySignature])
|
|
4774
4780
|
React.useEffect(() => {
|
|
4775
4781
|
const current = new Set(imageKeys)
|
|
4776
4782
|
for (const key of ownedImageKeys.current) {
|
|
@@ -4810,7 +4816,7 @@ openLogFolder,
|
|
|
4810
4816
|
}
|
|
4811
4817
|
return React.createElement(
|
|
4812
4818
|
'div',
|
|
4813
|
-
{ 'data-dvr-present': 'true', style: { display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 6, padding: '4px 0' } },
|
|
4819
|
+
{ ref: presentNode, 'data-dvr-present': 'true', style: { display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 6, padding: '4px 0' } },
|
|
4814
4820
|
parsed && typeof parsed.label === 'string' && parsed.label !== 'image'
|
|
4815
4821
|
? React.createElement(
|
|
4816
4822
|
'div',
|
package/lib/core-primitives.js
CHANGED
|
@@ -2768,6 +2768,8 @@ export function decideVisionBackendCapability(info, provider, model, extraVision
|
|
|
2768
2768
|
image: true,
|
|
2769
2769
|
attemptable: true,
|
|
2770
2770
|
inputModalities: [...new Set([...inputModalities, 'image'])],
|
|
2771
|
+
...(inputModalities.length > 0 && !inputModalities.includes('image')
|
|
2772
|
+
? { hostInputModalities: [...inputModalities] } : {}),
|
|
2771
2773
|
inferred: 'override',
|
|
2772
2774
|
reason: undefined,
|
|
2773
2775
|
}
|
|
@@ -2789,6 +2791,8 @@ export function decideVisionBackendCapability(info, provider, model, extraVision
|
|
|
2789
2791
|
image: true,
|
|
2790
2792
|
attemptable: true,
|
|
2791
2793
|
inputModalities: [...new Set([...inputModalities, 'image'])],
|
|
2794
|
+
...(inputModalities.length > 0 && !inputModalities.includes('image')
|
|
2795
|
+
? { hostInputModalities: [...inputModalities] } : {}),
|
|
2792
2796
|
inferred: 'name',
|
|
2793
2797
|
reason: undefined,
|
|
2794
2798
|
}
|
|
@@ -20,9 +20,15 @@ const CREDENTIAL_SHAPE_PATTERNS = [
|
|
|
20
20
|
// (the log sink has always consumed it) while `,` separates what follows.
|
|
21
21
|
[/\b(Authorization\s*[:=]\s*)(?:[A-Za-z][A-Za-z0-9._-]*\s+)?[^\s,]+/gi, `$1${REDACTED}`],
|
|
22
22
|
[/\b(Proxy-Authorization\s*[:=]\s*)(?:[A-Za-z][A-Za-z0-9._-]*\s+)?[^\s,]+/gi, `$1${REDACTED}`],
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
23
|
+
// A real Cookie/Set-Cookie header owns the remainder of its line, so redact the
|
|
24
|
+
// whole value there. The same words also appear inside arbitrary diagnostics,
|
|
25
|
+
// URL query fragments and object dumps; those contexts must keep unrelated text.
|
|
26
|
+
[/^([ \t]*(?:Set-)?Cookie\s*:\s*)[^\r\n]+/gim, `$1${REDACTED}`],
|
|
27
|
+
// Inline colon form: redact one scalar or a semicolon-separated cookie-pair run,
|
|
28
|
+
// then stop at an unrelated comma/word so support diagnostics remain readable.
|
|
29
|
+
[/\b((?:Set-)?Cookie\s*:\s*)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^;\s,&"']+(?:\s*;\s*[^=;\s,&"']+\s*=\s*(?:"[^"\r\n]*"|'[^'\r\n]*'|[^;\s,&"']+))*)/gi, `$1${REDACTED}`],
|
|
30
|
+
// Assignment/query form: bound the value at ordinary diagnostic separators.
|
|
31
|
+
[/\b((?:Set-)?Cookie\s*=\s*)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s,&"']+)/gi, `$1${REDACTED}`],
|
|
26
32
|
// URL userinfo (`scheme://user:secret@host`) without needing a URL parser, so
|
|
27
33
|
// the log path — which never parsed URLs — covers it too.
|
|
28
34
|
[/\b([a-z][a-z0-9+.-]*:\/\/)[^/\s@]+@/gi, `$1${REDACTED}@`],
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto'
|
|
2
2
|
import { createCachedUpdateChecker } from './update-check.js'
|
|
3
|
+
import { isLocalUiRequest } from './web-capability-boundary.js'
|
|
3
4
|
import { detectDshSelfUpdatePlan, runDshPluginUpdate } from './self-update.js'
|
|
4
5
|
|
|
5
6
|
function json(res, status, body, headers = {}) {
|
|
@@ -66,7 +67,12 @@ export function installVisionMaintenanceRoutes(ctx, options = {}) {
|
|
|
66
67
|
res.end()
|
|
67
68
|
return
|
|
68
69
|
}
|
|
69
|
-
|
|
70
|
+
// Forced refresh causes outbound Host network traffic. Remote Web may
|
|
71
|
+
// read already-cached update status, but must never bypass the cache
|
|
72
|
+
// with ?force=1. Reuse the existing local-UI provenance boundary;
|
|
73
|
+
// do not trust the query string or caller-supplied proxy headers.
|
|
74
|
+
const requestedForce = /(?:[?&])force=1(?:&|$)/.test(String(req.url ?? ''))
|
|
75
|
+
const force = requestedForce && isLocalUiRequest(req)
|
|
70
76
|
const result = await updateChecker.check(force)
|
|
71
77
|
json(res, 200, updateResultForClient(result), { 'cache-control': 'no-store' })
|
|
72
78
|
},
|
package/lib/proxy-routing.js
CHANGED
|
@@ -9,6 +9,21 @@ export function visionProxyOverrideConfigured(config = {}) {
|
|
|
9
9
|
: undefined;
|
|
10
10
|
return typeof value === 'string' && value.trim() !== '';
|
|
11
11
|
}
|
|
12
|
+
function canonicalIpv6Literal(value) {
|
|
13
|
+
if (!value.includes(':'))
|
|
14
|
+
return undefined;
|
|
15
|
+
try {
|
|
16
|
+
const serialized = new URL(`http://[${value}]/`).hostname;
|
|
17
|
+
if (!serialized.startsWith('[') || !serialized.endsWith(']'))
|
|
18
|
+
return undefined;
|
|
19
|
+
return serialized.slice(1, -1).toLowerCase();
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// Invalid or URL-unrepresentable literals (for example zone-id spellings)
|
|
23
|
+
// keep the existing conservative string semantics instead of broadening admission.
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
12
27
|
/**
|
|
13
28
|
* Canonicalize a configured/request hostname for proxy admission only.
|
|
14
29
|
* Persisted settings stay untouched; this removes DNS presentation differences
|
|
@@ -22,26 +37,30 @@ export function canonicalProxyHost(value) {
|
|
|
22
37
|
host = host.slice(0, -1);
|
|
23
38
|
if (host === '')
|
|
24
39
|
return '';
|
|
25
|
-
//
|
|
26
|
-
// already does for a bare hostname.
|
|
27
|
-
// makes the entry equal no real hostname: the configured host silently proxies nothing.
|
|
40
|
+
// `*.host` and cookie-domain `.host` entries both mean "this host and its subdomains",
|
|
41
|
+
// which is exactly what the matcher already does for a bare hostname. Leaving either prefix
|
|
42
|
+
// in place makes the entry equal no real hostname: the configured host silently proxies nothing.
|
|
28
43
|
if (host.startsWith('*.'))
|
|
29
44
|
host = host.slice(2);
|
|
45
|
+
else if (host.startsWith('.'))
|
|
46
|
+
host = host.slice(1);
|
|
30
47
|
if (host === '')
|
|
31
48
|
return '';
|
|
32
|
-
// WHATWG URL.hostname
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
49
|
+
// WHATWG URL.hostname serializes IPv6 literals to one compressed spelling.
|
|
50
|
+
// Re-serialize configured literals through the same parser before comparing so
|
|
51
|
+
// equivalent legal spellings (expanded/compressed, case, leading zeroes and
|
|
52
|
+
// IPv4-mapped forms) cannot silently bypass a proxy override. A bracketed
|
|
53
|
+
// non-literal keeps its spelling, and invalid/URL-unrepresentable colon forms
|
|
54
|
+
// retain the previous lowercase-only fallback rather than widening admission.
|
|
37
55
|
if (host.startsWith('[') && host.endsWith(']')) {
|
|
38
56
|
const literal = host.slice(1, -1).trim();
|
|
39
57
|
if (literal === '')
|
|
40
58
|
return '';
|
|
41
|
-
return
|
|
59
|
+
return canonicalIpv6Literal(literal)
|
|
60
|
+
?? (literal.includes(':') ? literal.toLowerCase() : host.toLowerCase());
|
|
42
61
|
}
|
|
43
62
|
if (host.includes(':'))
|
|
44
|
-
return host.toLowerCase();
|
|
63
|
+
return canonicalIpv6Literal(host) ?? host.toLowerCase();
|
|
45
64
|
const ascii = domainToASCII(host);
|
|
46
65
|
return (ascii || host).toLowerCase();
|
|
47
66
|
}
|
|
@@ -174,7 +174,7 @@ async function toOpenAIMessages(ctx, messages) {
|
|
|
174
174
|
return out
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
async function resolvedHostInfo(llm, call) {
|
|
177
|
+
export async function resolvedHostInfo(llm, call) {
|
|
178
178
|
if (typeof llm?.prepareCall === 'function') {
|
|
179
179
|
try {
|
|
180
180
|
const prepared = await llm.prepareCall(
|
|
@@ -195,7 +195,7 @@ async function resolvedHostInfo(llm, call) {
|
|
|
195
195
|
return undefined
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
-
function localOnlyVisionCallAllowed(core, config, provider, model) {
|
|
198
|
+
export function localOnlyVisionCallAllowed(core, config, provider, model) {
|
|
199
199
|
if (config?.localOnlyVision !== true) return true
|
|
200
200
|
if (provider !== 'vision-http') return false
|
|
201
201
|
const allowed = []
|
|
@@ -346,7 +346,7 @@ function llmWithRuntimePolicy(ctx, llm, options) {
|
|
|
346
346
|
plan.reason,
|
|
347
347
|
)
|
|
348
348
|
yield* failureStream(
|
|
349
|
-
`vision backend ${provider}/${model} is declared text-only by the Host
|
|
349
|
+
`vision backend ${provider}/${model} is declared text-only by the Host, which strips image pixels before the registered adapter runs; no safe HTTP image bridge is available. If this endpoint really supports images, enable Image under DSH Settings > Models > provider > Customized settings > Model options > Input types (when available); on older Hosts set the provider-specific model field (llm-pi-ai: input: [text, image]; llm-deepseek: inputModalities: [text, image]), then reload model configuration. Otherwise choose a declared image-capable backend. Direct bridging is supported only for verified HTTP OpenAI Chat Completions endpoints.`,
|
|
350
350
|
)
|
|
351
351
|
return
|
|
352
352
|
}
|