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.
@@ -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 user-selected generative model. It is
77
- not treated as proof that the model cannot inspect images. **Test Vision** always
78
- tries the exact current dropdown selection when its live adapter can be invoked.
79
- If the user has explicitly enabled `backgroundBenchmarking`, eligible configured
80
- models are also actually measured within that cost scope even when Host metadata
81
- currently labels them text-only. The measured result, rather than the Host label,
82
- is the capability evidence used by Auto.
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 may still send generated test images to verify the model
105
- when the user explicitly starts the Benchmark. The implementation may carry an
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 preparedAdapterCompat = new WeakMap()
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. Vision Router intentionally registers plain adapter
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
- if (!needsPrepareCall && !needsImageRequestPricing) return adapter
75
+ const needsImageRequestTarget = includeImageRequestTarget && !hasFunction(adapter, 'imageRequestTarget')
76
+ if (!needsPrepareCall && !needsImageRequestPricing && !needsImageRequestTarget) return adapter
68
77
 
69
- const cache = includeImageRequestPricing ? fullAdapterContractCompat : preparedAdapterCompat
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, { imageRequestPricing: includeImageRequestPricing }),
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+, and imageRequestPricing when
242
- * the Host exposes the DSH 0.1.2 route-pricing seam. This stays scoped to the
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 that mounts below the reader is the same as not being shown: the card
1000
- // renders next to its tool call, and a reader who is at the end of a long conversation has
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
- if (typeof document === 'undefined') return false;
1028
- var cards = document.querySelectorAll('[data-dvr-present]');
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') return;
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
- void props.select(target).then(function(accepted){
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: '视觉能力元数据暂时不可用;模型仍可选择,实际可用性将由供应商已注册的 DSH adapter 在调用时验证。',
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: '如果这里有你刚在「设置 → 模型 → 添加自定义提供方」中添加的视觉模型:可以在高级设置「额外视觉模型」的下拉里选中它直接启用;或按 DSH 的方式在 $DSH_HOME/settings.yaml 为该模型补上 input: [text, image](或为整个提供方补 defaultInput: [text, image])。DSH 当前 Web 表单不会写入这个字段。',
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
- '实际调用仍优先走 DSH adapter;只有明确的 http(s) OpenAI Chat Completions 渠道才可能使用直连兼容兜底。',
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: 'Vision capability metadata is temporarily unavailable; models remain selectable and the registered DSH adapter will verify actual support when called.',
558
- visionCapsFiltered: 'Capability metadata is advisory, not an admission gate. Generative models with undeclared or text-only metadata remain selectable and failures automatically fall through.',
559
- visionCapabilityUndeclaredWarning: 'This model has no image-capability declaration, but it stays usable: Vision Router tries it on the call and falls through automatically on failure.',
560
- visionCapabilityTextOnlyWarning: 'This model is marked text-only, but you can still use it: image support is verified on the call and failures fall through automatically.',
561
- visionCapabilityUnknownWarning: 'Image capability is verified automatically on the call — no extra setup needed; failures fall through to the next model.',
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: 'If one of these is a vision model you just added through Settings → Models → Add custom provider: select it in the “Extra vision models” dropdown under Advanced to enable it directly; or follow the DSH way and add input: [text, image] to that model in $DSH_HOME/settings.yaml (or defaultInput: [text, image] to the provider). The current DSH Web form does not write this field.',
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
- 'Runtime still tries the DSH adapter first; only a confirmed http(s) OpenAI Chat Completions channel may use the direct compatibility bridge.',
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 || capability.image === true) return undefined
849
- const modalities = Array.isArray(capability.inputModalities) ? capability.inputModalities : []
850
- return modalities.length > 0 && !modalities.includes('image')
851
- ? 'visionCapabilityTextOnlyWarning'
852
- : 'visionCapabilityUndeclaredWarning'
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
- // Follow the app language: register our dictionaries and re-read them
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
- React.useEffect(() => { if (globalThis.__dvrPresentFocus) globalThis.__dvrPresentFocus() }, [imageKeySignature])
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',
@@ -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
- // Cookies are `name=value; name=value` on one line and there is no safe way to
24
- // guess which pair carries the session, so the whole header value goes.
25
- [/\b((?:Set-)?Cookie\s*[:=]\s*)[^\r\n]+/gi, `$1${REDACTED}`],
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
- const force = /(?:[?&])force=1(?:&|$)/.test(String(req.url ?? ''))
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
  },
@@ -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
- // A `*.host` entry means "this host and its subdomains", which is exactly what the matcher
26
- // already does for a bare hostname. `domainToASCII` rejects the star, so leaving it in place
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 keeps IPv6 literals bracketed. domainToASCII does not
33
- // accept an IPv6 literal at all, so case-fold it directly. The brackets are
34
- // pure spelling for the same address: stripping them is what lets a configured
35
- // `::1` match a URL-derived `[::1]`. A bracketed non-literal (say
36
- // `[example.com]`) keeps its spelling, so nothing about DNS is widened.
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 literal.includes(':') ? literal.toLowerCase() : host.toLowerCase();
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 and no safe pixel transport is available`,
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
  }