dsh-vision-router 2.1.6 → 2.1.7
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 +3 -3
- package/README.zh.md +3 -3
- package/cordis.patch.yml +14 -0
- package/docs/releases/v2.1.7.md +13 -0
- package/index.js +34 -15
- package/lib/client.js +271 -31
- package/lib/core-primitives.js +7 -2
- package/lib/live-model-discovery.js +125 -15
- package/lib/remote-settings-bridge.js +9 -4
- package/lib/replay-delegation.js +35 -29
- package/lib/runtime-config-normalizer.js +271 -1
- package/lib/vision-background-stop-store.js +30 -20
- package/lib/vision-capability-probe.js +24 -14
- package/lib/vision-image-input-verdict.js +22 -7
- package/lib/web-capability-boundary.js +85 -22
- package/package.json +6 -4
|
@@ -5,6 +5,7 @@ import process from 'node:process'
|
|
|
5
5
|
import { resolveDshHome } from './doctor.js'
|
|
6
6
|
import { MODEL_RESPONSE_MAX_BYTES, readResponseJsonBounded } from './http-body-limit.js'
|
|
7
7
|
import { stripTrailingSlashes } from './string-normalization.js'
|
|
8
|
+
import { isLocalUiRequest } from './web-capability-boundary.js'
|
|
8
9
|
|
|
9
10
|
export const LIVE_MODELS_PATH = '/_dsh/vision-router/live-models'
|
|
10
11
|
export const LIVE_MODEL_CACHE_VERSION = 2
|
|
@@ -161,17 +162,34 @@ async function saveCache(file, providers, fsOps) {
|
|
|
161
162
|
await fsOps.rename(temporary, file)
|
|
162
163
|
}
|
|
163
164
|
|
|
164
|
-
function
|
|
165
|
+
function piProfilesState(ctx) {
|
|
165
166
|
try {
|
|
166
167
|
const settings = ctx?.get?.('settings')
|
|
167
|
-
|
|
168
|
+
if (!settings || typeof settings.get !== 'function') return { authoritative: false, providers: {} }
|
|
169
|
+
const value = settings.get('llm-pi-ai')
|
|
168
170
|
const providers = value?.providers
|
|
169
|
-
|
|
171
|
+
if (!providers || typeof providers !== 'object' || Array.isArray(providers)) {
|
|
172
|
+
return { authoritative: false, providers: {} }
|
|
173
|
+
}
|
|
174
|
+
return { authoritative: true, providers }
|
|
170
175
|
} catch {
|
|
171
|
-
return {}
|
|
176
|
+
return { authoritative: false, providers: {} }
|
|
172
177
|
}
|
|
173
178
|
}
|
|
174
179
|
|
|
180
|
+
function rawPiProfiles(ctx) {
|
|
181
|
+
return piProfilesState(ctx).providers
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function providerConfigurationToken(provider, raw) {
|
|
185
|
+
return JSON.stringify([
|
|
186
|
+
provider,
|
|
187
|
+
normalizeBaseURL(raw?.baseURL) ?? '',
|
|
188
|
+
nonEmpty(raw?.api) ?? '',
|
|
189
|
+
nonEmpty(raw?.apiKeyEnv) ?? '',
|
|
190
|
+
])
|
|
191
|
+
}
|
|
192
|
+
|
|
175
193
|
function visionProviderPriority(ctx, fallbackConfig = {}) {
|
|
176
194
|
const preferred = new Set()
|
|
177
195
|
const collect = (value) => {
|
|
@@ -349,15 +367,18 @@ export function createLiveModelDiscoveryManager(ctx, options = {}) {
|
|
|
349
367
|
let providers = new Map()
|
|
350
368
|
let version = 0
|
|
351
369
|
let evidenceGeneration = 0
|
|
370
|
+
let configuredProviders
|
|
371
|
+
let configuredTokens = new Map()
|
|
372
|
+
let providerGenerations = new Map()
|
|
373
|
+
let providerGenerationSequence = 0
|
|
352
374
|
let active = 0
|
|
353
375
|
let disposed = false
|
|
354
376
|
let saveTail = Promise.resolve()
|
|
355
377
|
const inflight = new Map()
|
|
378
|
+
const inflightAuthority = new Map()
|
|
356
379
|
const queued = new Map()
|
|
357
380
|
const backoffUntil = new Map()
|
|
358
|
-
const
|
|
359
|
-
if (!disposed) providers = loaded
|
|
360
|
-
})
|
|
381
|
+
const activeControllers = new Map()
|
|
361
382
|
|
|
362
383
|
const persist = () => {
|
|
363
384
|
saveTail = saveTail
|
|
@@ -366,6 +387,76 @@ export function createLiveModelDiscoveryManager(ctx, options = {}) {
|
|
|
366
387
|
return saveTail
|
|
367
388
|
}
|
|
368
389
|
|
|
390
|
+
const pruneUnconfiguredState = () => {
|
|
391
|
+
if (!(configuredProviders instanceof Set)) return false
|
|
392
|
+
let providerCacheChanged = false
|
|
393
|
+
for (const provider of [...providers.keys()]) {
|
|
394
|
+
if (configuredProviders.has(provider)) continue
|
|
395
|
+
providers.delete(provider)
|
|
396
|
+
providerCacheChanged = true
|
|
397
|
+
}
|
|
398
|
+
for (const provider of [...queued.keys()]) {
|
|
399
|
+
if (!configuredProviders.has(provider)) queued.delete(provider)
|
|
400
|
+
}
|
|
401
|
+
for (const provider of [...backoffUntil.keys()]) {
|
|
402
|
+
if (!configuredProviders.has(provider)) backoffUntil.delete(provider)
|
|
403
|
+
}
|
|
404
|
+
for (const [provider, controller] of activeControllers.entries()) {
|
|
405
|
+
if (configuredProviders.has(provider)) continue
|
|
406
|
+
try { controller.abort() } catch {}
|
|
407
|
+
}
|
|
408
|
+
if (providerCacheChanged) version += 1
|
|
409
|
+
return providerCacheChanged
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const reconcileConfiguredProviders = () => {
|
|
413
|
+
const state = piProfilesState(ctx)
|
|
414
|
+
if (!state.authoritative) return state.providers
|
|
415
|
+
const next = new Set(Object.keys(state.providers))
|
|
416
|
+
const nextTokens = new Map([...next].map((provider) => [
|
|
417
|
+
provider,
|
|
418
|
+
providerConfigurationToken(provider, state.providers[provider]),
|
|
419
|
+
]))
|
|
420
|
+
const nextGenerations = new Map()
|
|
421
|
+
for (const provider of next) {
|
|
422
|
+
const unchanged = configuredProviders instanceof Set &&
|
|
423
|
+
configuredProviders.has(provider) &&
|
|
424
|
+
configuredTokens.get(provider) === nextTokens.get(provider)
|
|
425
|
+
if (unchanged && providerGenerations.has(provider)) {
|
|
426
|
+
nextGenerations.set(provider, providerGenerations.get(provider))
|
|
427
|
+
} else {
|
|
428
|
+
providerGenerationSequence += 1
|
|
429
|
+
nextGenerations.set(provider, providerGenerationSequence)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
configuredProviders = next
|
|
433
|
+
configuredTokens = nextTokens
|
|
434
|
+
providerGenerations = nextGenerations
|
|
435
|
+
if (pruneUnconfiguredState()) void persist()
|
|
436
|
+
return state.providers
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const currentDiscoveryAuthority = (provider) => ({
|
|
440
|
+
providerGeneration: providerGenerations.get(provider) ?? 0,
|
|
441
|
+
evidenceGeneration,
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
const sameDiscoveryAuthority = (left, right) => (
|
|
445
|
+
left?.providerGeneration === right?.providerGeneration &&
|
|
446
|
+
left?.evidenceGeneration === right?.evidenceGeneration
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
const discoveryStillCurrent = (provider, authority) => (
|
|
450
|
+
sameDiscoveryAuthority(authority, currentDiscoveryAuthority(provider)) &&
|
|
451
|
+
(!(configuredProviders instanceof Set) || configuredProviders.has(provider))
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
const cacheReady = loadCache(cacheFile, fsOps).then((loaded) => {
|
|
455
|
+
if (disposed) return
|
|
456
|
+
providers = loaded
|
|
457
|
+
reconcileConfiguredProviders()
|
|
458
|
+
})
|
|
459
|
+
|
|
369
460
|
const visibleEntry = (entry, at = now()) => {
|
|
370
461
|
if (!entry || entry.routeMismatch === true || at - entry.discoveredAt > staleMs) return undefined
|
|
371
462
|
const currentEvidence = entry.evidenceGeneration === evidenceGeneration
|
|
@@ -391,10 +482,10 @@ export function createLiveModelDiscoveryManager(ctx, options = {}) {
|
|
|
391
482
|
}
|
|
392
483
|
}
|
|
393
484
|
|
|
394
|
-
const discover = async (provider) => {
|
|
485
|
+
const discover = async (provider, authorityAtStart) => {
|
|
395
486
|
const plan = await providerPlan(ctx, provider)
|
|
396
|
-
if (!plan.ok) return
|
|
397
|
-
const generationAtStart = evidenceGeneration
|
|
487
|
+
if (!plan.ok || disposed || !discoveryStillCurrent(provider, authorityAtStart)) return
|
|
488
|
+
const generationAtStart = authorityAtStart.evidenceGeneration
|
|
398
489
|
const previous = providers.get(provider)
|
|
399
490
|
const at = now()
|
|
400
491
|
const sameRoute = previous?.fingerprint === plan.fingerprint
|
|
@@ -414,6 +505,7 @@ export function createLiveModelDiscoveryManager(ctx, options = {}) {
|
|
|
414
505
|
}
|
|
415
506
|
|
|
416
507
|
const controller = new AbortController()
|
|
508
|
+
activeControllers.set(provider, controller)
|
|
417
509
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
418
510
|
try {
|
|
419
511
|
const headers = { accept: 'application/json' }
|
|
@@ -432,6 +524,7 @@ export function createLiveModelDiscoveryManager(ctx, options = {}) {
|
|
|
432
524
|
label: `${provider} model listing`,
|
|
433
525
|
})
|
|
434
526
|
const models = normalizeOpenAIModelListing(body, { maxModels: options.maxModels })
|
|
527
|
+
if (disposed || !discoveryStillCurrent(provider, authorityAtStart)) return
|
|
435
528
|
providers.set(provider, {
|
|
436
529
|
provider,
|
|
437
530
|
fingerprint: plan.fingerprint,
|
|
@@ -444,7 +537,7 @@ export function createLiveModelDiscoveryManager(ctx, options = {}) {
|
|
|
444
537
|
version += 1
|
|
445
538
|
void persist()
|
|
446
539
|
} catch (error) {
|
|
447
|
-
if (disposed) return
|
|
540
|
+
if (disposed || !discoveryStillCurrent(provider, authorityAtStart)) return
|
|
448
541
|
const current = providers.get(provider)
|
|
449
542
|
const message = boundedError(error)
|
|
450
543
|
if (current) {
|
|
@@ -455,6 +548,7 @@ export function createLiveModelDiscoveryManager(ctx, options = {}) {
|
|
|
455
548
|
logger?.debug?.('vision-router: live model discovery failed for %s: %s', provider, message)
|
|
456
549
|
} finally {
|
|
457
550
|
clearTimeout(timer)
|
|
551
|
+
if (activeControllers.get(provider) === controller) activeControllers.delete(provider)
|
|
458
552
|
}
|
|
459
553
|
}
|
|
460
554
|
|
|
@@ -470,14 +564,17 @@ export function createLiveModelDiscoveryManager(ctx, options = {}) {
|
|
|
470
564
|
if (!picked) return
|
|
471
565
|
queued.delete(picked.provider)
|
|
472
566
|
active += 1
|
|
473
|
-
const
|
|
567
|
+
const authorityAtStart = currentDiscoveryAuthority(picked.provider)
|
|
568
|
+
const task = discover(picked.provider, authorityAtStart)
|
|
474
569
|
.catch(() => {})
|
|
475
570
|
.finally(() => {
|
|
476
571
|
active = Math.max(0, active - 1)
|
|
477
572
|
inflight.delete(picked.provider)
|
|
573
|
+
inflightAuthority.delete(picked.provider)
|
|
478
574
|
pump()
|
|
479
575
|
})
|
|
480
576
|
inflight.set(picked.provider, task)
|
|
577
|
+
inflightAuthority.set(picked.provider, authorityAtStart)
|
|
481
578
|
}
|
|
482
579
|
}
|
|
483
580
|
|
|
@@ -491,9 +588,13 @@ export function createLiveModelDiscoveryManager(ctx, options = {}) {
|
|
|
491
588
|
}
|
|
492
589
|
|
|
493
590
|
function queueConfigured() {
|
|
494
|
-
const raw =
|
|
591
|
+
const raw = reconcileConfiguredProviders()
|
|
495
592
|
const preferred = visionProviderPriority(ctx, fallbackConfig)
|
|
496
|
-
for (const provider of Object.keys(raw))
|
|
593
|
+
for (const provider of Object.keys(raw)) {
|
|
594
|
+
const activeAuthority = inflightAuthority.get(provider)
|
|
595
|
+
if (activeAuthority && sameDiscoveryAuthority(activeAuthority, currentDiscoveryAuthority(provider))) continue
|
|
596
|
+
queue(provider, preferred.has(provider) ? 0 : 10)
|
|
597
|
+
}
|
|
497
598
|
}
|
|
498
599
|
|
|
499
600
|
const hasModel = (provider, model) => {
|
|
@@ -524,7 +625,12 @@ export function createLiveModelDiscoveryManager(ctx, options = {}) {
|
|
|
524
625
|
async dispose() {
|
|
525
626
|
disposed = true
|
|
526
627
|
queued.clear()
|
|
628
|
+
for (const controller of activeControllers.values()) {
|
|
629
|
+
try { controller.abort() } catch {}
|
|
630
|
+
}
|
|
527
631
|
await Promise.allSettled([...inflight.values(), cacheReady, saveTail])
|
|
632
|
+
activeControllers.clear()
|
|
633
|
+
inflightAuthority.clear()
|
|
528
634
|
},
|
|
529
635
|
}
|
|
530
636
|
}
|
|
@@ -579,7 +685,11 @@ export function installLiveModelDiscovery(ctx, options = {}) {
|
|
|
579
685
|
}
|
|
580
686
|
try {
|
|
581
687
|
const url = new URL(req.url ?? LIVE_MODELS_PATH, 'http://localhost')
|
|
582
|
-
|
|
688
|
+
// The snapshot itself is safe for an authenticated remote Settings UI,
|
|
689
|
+
// but refresh drives Host-side provider I/O and may resolve stored
|
|
690
|
+
// credentials. Preserve local ?refresh=1 behavior while making every
|
|
691
|
+
// remote GET a passive read of already-owned discovery state.
|
|
692
|
+
const schedule = isLocalUiRequest(req) && url.searchParams.get('refresh') !== '0'
|
|
583
693
|
sendJson(res, 200, await manager.snapshot({ schedule }))
|
|
584
694
|
} catch (error) {
|
|
585
695
|
sendJson(res, 500, { ok: false, error: boundedError(error) })
|
|
@@ -3,6 +3,7 @@ import { installRemoteSettingsRiskConfirmationBridge } from './remote-settings-r
|
|
|
3
3
|
import { installSettingsIaClientPrelude } from './settings-ia-client-prelude.js'
|
|
4
4
|
import { installLegacySettingsMigration } from './settings-migration.js'
|
|
5
5
|
import { installV2SettingsIaIntegration } from './v2-settings-ia-integration.js'
|
|
6
|
+
import { remoteConfigFieldAdmissionError } from './runtime-config-normalizer.js'
|
|
6
7
|
|
|
7
8
|
const SETTINGS_NS = 'vision-router'
|
|
8
9
|
|
|
@@ -107,6 +108,10 @@ function validateMutation(payload, descriptor) {
|
|
|
107
108
|
if (!mutable.has(field)) return { error: 'Vision Router setting ' + JSON.stringify(field) + ' is local-only' }
|
|
108
109
|
const value = descriptor && descriptor.value
|
|
109
110
|
if (typeof value !== 'object' || value === null || Array.isArray(value) || !Object.prototype.hasOwnProperty.call(value, field)) return { error: 'unknown Vision Router settings field ' + JSON.stringify(field) }
|
|
111
|
+
if (op.op === 'set') {
|
|
112
|
+
const budgetError = remoteConfigFieldAdmissionError(field, op.value)
|
|
113
|
+
if (budgetError) return { error: budgetError }
|
|
114
|
+
}
|
|
110
115
|
return { ops: [op], expectedRevision }
|
|
111
116
|
}
|
|
112
117
|
|
|
@@ -213,10 +218,10 @@ export function createVisionRouterRemoteSettingsHandler(settings, logger) {
|
|
|
213
218
|
}
|
|
214
219
|
|
|
215
220
|
export function installVisionRouterRemoteSettingsBridge(ctx, logger) {
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
221
|
+
// Declare the services this caller uses directly. Current DSH Connection
|
|
222
|
+
// accessors additionally trace their route owner through the Connection
|
|
223
|
+
// provider fiber; cordis.patch.yml therefore keeps that provider activated
|
|
224
|
+
// on webServer as a separate compatibility boundary (#478).
|
|
220
225
|
ctx.inject(['settings', 'connection', 'webServer'], (remoteCtx) => {
|
|
221
226
|
const handler = createVisionRouterRemoteSettingsHandler(remoteCtx.settings, logger ?? remoteCtx.logger)
|
|
222
227
|
remoteCtx.effect(
|
package/lib/replay-delegation.js
CHANGED
|
@@ -12,7 +12,6 @@ const dynamicWrapperAdapters = new WeakMap()
|
|
|
12
12
|
const NATIVE_DEEPSEEK_ROUTE = 'deepseek-official-native'
|
|
13
13
|
const OFFICIAL_DEEPSEEK_ROUTE = 'deepseek-official'
|
|
14
14
|
const VISION_HTTP_ROUTE = 'vision-http'
|
|
15
|
-
const MAIN_WRAPPER_MODEL_IDS = new Set(['deepseek-v4-pro', 'deepseek-v4-flash'])
|
|
16
15
|
|
|
17
16
|
// One entry per (sessionId, provider, model) tuple: a long-running dsh web
|
|
18
17
|
// process would otherwise grow the reasoning-effort memory without bound as
|
|
@@ -153,45 +152,52 @@ function withoutReasoningEffort(options) {
|
|
|
153
152
|
return rest
|
|
154
153
|
}
|
|
155
154
|
|
|
156
|
-
async function
|
|
155
|
+
async function mainWrapperCatalog(ctx) {
|
|
157
156
|
// When the hidden native route is active the public stock DeepSeek row owns
|
|
158
157
|
// the picker and the legacy wrapper is intentionally hidden.
|
|
159
|
-
if (nativeDeepSeekActive(ctx)) return []
|
|
158
|
+
if (nativeDeepSeekActive(ctx)) return { adapter: undefined, models: [] }
|
|
160
159
|
let registration
|
|
161
160
|
try {
|
|
162
161
|
registration = ctx?.llm?.registration(OFFICIAL_DEEPSEEK_ROUTE)
|
|
163
162
|
} catch {
|
|
164
|
-
return []
|
|
163
|
+
return { adapter: undefined, models: [] }
|
|
165
164
|
}
|
|
166
165
|
const adapter = registration && registration.adapter
|
|
167
|
-
if (!adapter || typeof adapter.listModels !== 'function')
|
|
166
|
+
if (!adapter || typeof adapter.listModels !== 'function') {
|
|
167
|
+
return { adapter, models: [] }
|
|
168
|
+
}
|
|
168
169
|
try {
|
|
169
170
|
const listed = await adapter.listModels(OFFICIAL_DEEPSEEK_ROUTE)
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
.
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
inputModalities: ['text', 'image'],
|
|
177
|
-
}))
|
|
171
|
+
return {
|
|
172
|
+
adapter,
|
|
173
|
+
models: (Array.isArray(listed) ? listed : []).filter(
|
|
174
|
+
(entry) => entry && isNonEmptyString(entry.id),
|
|
175
|
+
),
|
|
176
|
+
}
|
|
178
177
|
} catch {
|
|
179
|
-
return []
|
|
178
|
+
return { adapter, models: [] }
|
|
180
179
|
}
|
|
181
180
|
}
|
|
182
181
|
|
|
182
|
+
async function mainWrapperModels(ctx, fallbackWrapperRoute) {
|
|
183
|
+
const { models } = await mainWrapperCatalog(ctx)
|
|
184
|
+
const route = currentWrapperRoute(ctx, fallbackWrapperRoute)
|
|
185
|
+
return models.map((model) => ({
|
|
186
|
+
...model,
|
|
187
|
+
provider: route,
|
|
188
|
+
inputModalities: ['text', 'image'],
|
|
189
|
+
}))
|
|
190
|
+
}
|
|
191
|
+
|
|
183
192
|
async function mainWrapperModel(ctx, fallbackWrapperRoute, model, signal) {
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
} catch {
|
|
191
|
-
return undefined
|
|
192
|
-
}
|
|
193
|
-
const adapter = registration && registration.adapter
|
|
193
|
+
// The special wrapper mirrors the *live official DeepSeek catalog* rather
|
|
194
|
+
// than a frozen list of model ids. DSH stable can add a new default model
|
|
195
|
+
// without requiring a Vision Router release, while membership still fails
|
|
196
|
+
// closed to the official provider and cannot follow textProvider/relay rows.
|
|
197
|
+
if (!isNonEmptyString(model) || nativeDeepSeekActive(ctx)) return undefined
|
|
198
|
+
const { adapter, models } = await mainWrapperCatalog(ctx)
|
|
194
199
|
if (!adapter || typeof adapter.resolveModel !== 'function') return undefined
|
|
200
|
+
if (!models.some((entry) => entry.id === model)) return undefined
|
|
195
201
|
const base = await adapter.resolveModel(OFFICIAL_DEEPSEEK_ROUTE, model, signal)
|
|
196
202
|
return {
|
|
197
203
|
...base,
|
|
@@ -246,6 +252,7 @@ function dynamicWrapperAdapter(adapter, ctx, fallbackWrapperRoute) {
|
|
|
246
252
|
const pinned = await mainWrapperModels(ctx, fallbackWrapperRoute)
|
|
247
253
|
if (typeof original !== 'function') return pinned
|
|
248
254
|
const route = currentWrapperRoute(ctx, fallbackWrapperRoute)
|
|
255
|
+
const pinnedIds = new Set(pinned.map((model) => model.id))
|
|
249
256
|
let composites = []
|
|
250
257
|
try {
|
|
251
258
|
const listed = await original.apply(target, args)
|
|
@@ -254,14 +261,13 @@ function dynamicWrapperAdapter(adapter, ctx, fallbackWrapperRoute) {
|
|
|
254
261
|
// ("provider/model(视觉)", published when whole-turn routing is
|
|
255
262
|
// enabled) so the legacy routing mode keeps its picker entries.
|
|
256
263
|
// These rows are structurally composite ids (`provider/model`);
|
|
257
|
-
// any DeepSeek mirror — and any other row the core
|
|
258
|
-
// is dropped here
|
|
259
|
-
// the
|
|
260
|
-
// possibly-stale textProvider.
|
|
264
|
+
// any official DeepSeek mirror — and any other row the core may
|
|
265
|
+
// emit — is dropped here. Normal DeepSeek models come only from
|
|
266
|
+
// the live official catalog above, never from textProvider.
|
|
261
267
|
composites = listed.filter(
|
|
262
268
|
(row) => row
|
|
263
269
|
&& row.provider === route
|
|
264
|
-
&& !
|
|
270
|
+
&& !pinnedIds.has(row.id)
|
|
265
271
|
&& String(row.id ?? '').includes('/'),
|
|
266
272
|
)
|
|
267
273
|
}
|