@tzyc/dsh-model-visibility 0.0.0-stage → 1.9.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.
@@ -0,0 +1,596 @@
1
+ /**
2
+ * Relay/provider probing for real capability metadata.
3
+ *
4
+ * DSH's LLM registry knows which provider routes exist, but for a custom
5
+ * relay (`llm-pi-ai` with `api: openai-completions` + a `baseURL`) the adapter
6
+ * only forwards the provider's static `defaultInput` — everything else is
7
+ * unknown. The relay itself often publishes more: an OpenAI-compatible
8
+ * `/v1/models` may carry `architecture.input_modalities` (OpenRouter format) or
9
+ * `input_modalities`, and New API style gateways expose `supported_endpoint_types`
10
+ * through `/api/pricing` (kind: chat / image-generation / embeddings / …).
11
+ *
12
+ * This module reads the provider profiles from the settings document, resolves
13
+ * each `apiKeyEnv` reference through the credentials seam *per read* (never
14
+ * cached, never returned), probes the endpoint with a short timeout and keeps
15
+ * only detached capability facts. Nothing here writes settings, stores keys or
16
+ * logs response bodies.
17
+ *
18
+ * Everything is best-effort: a failed probe produces a diagnostic and the page
19
+ * falls back to the adapter declaration + built-in catalog.
20
+ *
21
+ * @module lib/provider-probe
22
+ */
23
+
24
+ /** Default lifetime of one probe snapshot. */
25
+ export const DEFAULT_PROBE_TTL_MS = 10 * 60_000
26
+ /** Per-request timeout; providers are probed concurrently. */
27
+ export const DEFAULT_PROBE_TIMEOUT_MS = 3500
28
+ /** Responses larger than this are refused instead of parsed. */
29
+ const MAX_BODY_BYTES = 4 * 1024 * 1024
30
+ /** Capabilities we are willing to forward. */
31
+ const MODALITIES = new Set(['text', 'image', 'audio', 'video', 'file'])
32
+
33
+ /** Read a non-empty string, or undefined. */
34
+ function str(value) {
35
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
36
+ }
37
+
38
+ /** Keep positive safe integers only. */
39
+ function positive(value) {
40
+ return Number.isSafeInteger(value) && value > 0 ? value : undefined
41
+ }
42
+
43
+ /**
44
+ * Normalize an adapter/endpoint modality list.
45
+ * @param value - unknown list.
46
+ * @returns detached lowercase modalities, or undefined when nothing remains.
47
+ */
48
+ export function normalizeModalities(value) {
49
+ if (!Array.isArray(value)) return undefined
50
+ const out = []
51
+ for (const item of value) {
52
+ if (typeof item !== 'string') continue
53
+ let id = item.trim().toLowerCase()
54
+ if (id === 'vision') id = 'image'
55
+ if (id === 'text_only') id = 'text'
56
+ if (!MODALITIES.has(id) || out.includes(id)) continue
57
+ out.push(id)
58
+ }
59
+ return out.length > 0 ? out : undefined
60
+ }
61
+
62
+ /** Parse OpenRouter's `"text+image->image"` modality notation. */
63
+ function splitArchitectureModality(value) {
64
+ if (typeof value !== 'string' || !value.includes('->')) return {}
65
+ const [left, right] = value.split('->', 2)
66
+ const parse = (part) => normalizeModalities(part.split('+').map((item) => item.trim()))
67
+ return { input: parse(left), output: parse(right) }
68
+ }
69
+
70
+ /** Map an endpoint-type name (New API) to a model kind. */
71
+ function kindOfEndpoint(type) {
72
+ if (typeof type !== 'string') return undefined
73
+ const id = type.toLowerCase()
74
+ if (id.includes('image')) return 'image'
75
+ if (id.includes('video')) return 'video'
76
+ if (id.includes('embedding')) return 'embedding'
77
+ if (id.includes('rerank')) return 'rerank'
78
+ if (id.includes('audio') || id.includes('speech') || id.includes('tts')) return 'audio'
79
+ if (id.includes('moderation') || id.includes('classifier') || id.includes('safety')) return 'classifier'
80
+ if (id.includes('translation')) return 'translation'
81
+ if (id.includes('chat') || id.includes('completion')) return 'chat'
82
+ return undefined
83
+ }
84
+
85
+ /** Map an explicit `type`/`model_type` field to a kind. */
86
+ function kindOfType(type) {
87
+ if (typeof type !== 'string') return undefined
88
+ const id = type.toLowerCase()
89
+ if (['chat', 'text', 'completion', 'language'].includes(id)) return 'chat'
90
+ if (['image', 'image-generation', 'imagegeneration'].includes(id)) return 'image'
91
+ if (['video', 'video-generation', 'videogeneration'].includes(id)) return 'video'
92
+ if (['embedding', 'embeddings', 'embed'].includes(id)) return 'embedding'
93
+ if (['rerank', 'reranker', 'reranking'].includes(id)) return 'rerank'
94
+ if (['audio', 'speech', 'tts', 'transcription'].includes(id)) return 'audio'
95
+ if (['classifier', 'moderation', 'guard', 'safety'].includes(id)) return 'classifier'
96
+ if (['translation', 'translate'].includes(id)) return 'translation'
97
+ return undefined
98
+ }
99
+
100
+ /** Kind implied by output modalities, when an endpoint publishes them. */
101
+ function kindOfOutput(output) {
102
+ if (!Array.isArray(output)) return undefined
103
+ if (output.includes('video')) return 'video'
104
+ if (output.includes('image')) return 'image'
105
+ if (output.includes('audio')) return 'audio'
106
+ if (output.includes('text')) return 'chat'
107
+ return undefined
108
+ }
109
+
110
+ /** Kinds implied by OpenRouter's `supported_parameters`-style capability lists. */
111
+ function reasoningOf(entry) {
112
+ const capabilities = entry?.capabilities
113
+ if (capabilities !== null && typeof capabilities === 'object') {
114
+ if (typeof capabilities.reasoning === 'boolean') return capabilities.reasoning
115
+ if (typeof capabilities.reasoning_effort === 'boolean') return capabilities.reasoning_effort
116
+ }
117
+ if (typeof entry?.reasoning === 'boolean') return entry.reasoning
118
+ const params = entry?.supported_parameters
119
+ if (Array.isArray(params)) {
120
+ for (const param of params) {
121
+ if (typeof param !== 'string') continue
122
+ const id = param.toLowerCase()
123
+ if (id === 'reasoning' || id === 'reasoning_effort' || id === 'thinking' || id === 'include_reasoning') return true
124
+ }
125
+ }
126
+ return undefined
127
+ }
128
+
129
+ /** Endpoint-type list from an array or a JSON-encoded string. */
130
+ function endpointTypesOf(raw) {
131
+ let value = raw?.supported_endpoint_types
132
+ if (typeof value === 'string') {
133
+ try {
134
+ value = JSON.parse(value)
135
+ } catch (_error) {
136
+ return []
137
+ }
138
+ }
139
+ return Array.isArray(value) ? value.map(kindOfEndpoint).filter(Boolean) : []
140
+ }
141
+
142
+ /** Read one advertised model entry from any endpoint dialect. */
143
+ export function readModelEntry(raw) {
144
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return undefined
145
+ const id = str(raw.id) ?? str(raw.model) ?? str(raw.model_name) ?? str(raw.name)
146
+ if (id === undefined) return undefined
147
+ const architecture = raw.architecture !== null && typeof raw.architecture === 'object' ? raw.architecture : undefined
148
+ const split = splitArchitectureModality(architecture?.modality)
149
+ const input =
150
+ normalizeModalities(architecture?.input_modalities) ??
151
+ normalizeModalities(raw.input_modalities) ??
152
+ normalizeModalities(raw.inputModalities) ??
153
+ normalizeModalities(raw.input) ??
154
+ (raw.supports_vision === true ||
155
+ raw.vision === true ||
156
+ architecture?.vision === true ||
157
+ raw.capabilities?.vision === true
158
+ ? ['text', 'image']
159
+ : undefined) ??
160
+ split.input
161
+ const output =
162
+ normalizeModalities(architecture?.output_modalities) ??
163
+ normalizeModalities(raw.output_modalities) ??
164
+ normalizeModalities(raw.outputModalities) ??
165
+ normalizeModalities(raw.output) ??
166
+ split.output
167
+ const endpointKinds = endpointTypesOf(raw)
168
+ const kind =
169
+ kindOfType(raw.type) ??
170
+ kindOfType(raw.model_type) ??
171
+ (endpointKinds.includes('image') ? 'image' : undefined) ??
172
+ (endpointKinds.includes('video') ? 'video' : undefined) ??
173
+ kindOfOutput(output) ??
174
+ endpointKinds[0]
175
+ const contextWindow =
176
+ positive(raw.context_length) ??
177
+ positive(raw.context_window) ??
178
+ positive(raw.contextWindow) ??
179
+ positive(raw.max_input_tokens) ??
180
+ positive(raw.limit?.context)
181
+ const maxTokens = entryMaxTokens(raw)
182
+ const reasoning = reasoningOf(raw)
183
+ return {
184
+ id,
185
+ ...(str(raw.name) === undefined ? {} : { name: raw.name.trim() }),
186
+ ...(input === undefined ? {} : { inputModalities: input }),
187
+ ...(output === undefined ? {} : { outputModalities: output }),
188
+ ...(kind === undefined ? {} : { kind }),
189
+ ...(reasoning === undefined ? {} : { reasoning }),
190
+ ...(contextWindow === undefined ? {} : { contextWindow }),
191
+ ...(maxTokens === undefined ? {} : { maxTokens }),
192
+ }
193
+ }
194
+
195
+ /** Aggregate the several spellings of an output cap in OpenAI-ish listings. */
196
+ function entryMaxTokens(raw) {
197
+ return (
198
+ positive(raw.max_output_tokens) ??
199
+ positive(raw.max_completion_tokens) ??
200
+ positive(raw.max_tokens) ??
201
+ positive(raw.maxTokens) ??
202
+ positive(raw.top_provider?.max_completion_tokens) ??
203
+ positive(raw.limit?.output)
204
+ )
205
+ }
206
+
207
+ /**
208
+ * Parse one `/models` response.
209
+ * @param body - parsed JSON in any dialect the relays use.
210
+ * @returns a Map of exact model id to detached capability facts.
211
+ */
212
+ export function parseModelListing(body) {
213
+ const out = new Map()
214
+ let entries = []
215
+ if (Array.isArray(body)) entries = body
216
+ else if (body !== null && typeof body === 'object') {
217
+ if (Array.isArray(body.data)) entries = body.data
218
+ else if (Array.isArray(body.models)) entries = body.models
219
+ else if (body.models !== null && typeof body.models === 'object') {
220
+ entries = Object.entries(body.models).map(([key, value]) => ({
221
+ id: key,
222
+ ...(value !== null && typeof value === 'object' ? value : {}),
223
+ }))
224
+ }
225
+ }
226
+ for (const raw of entries) {
227
+ const record = readModelEntry(raw)
228
+ if (record === undefined) continue
229
+ out.set(record.id, record)
230
+ }
231
+ return out
232
+ }
233
+
234
+ /**
235
+ * Parse New API's `/api/pricing` response (model kind only).
236
+ * @param body - parsed JSON.
237
+ * @returns a Map of model id to `{ kind }`.
238
+ */
239
+ export function parsePricing(body) {
240
+ const out = new Map()
241
+ const data = body !== null && typeof body === 'object' && Array.isArray(body.data) ? body.data : []
242
+ for (const raw of data) {
243
+ if (raw === null || typeof raw !== 'object') continue
244
+ const id = str(raw.model_name) ?? str(raw.model) ?? str(raw.modelName)
245
+ if (id === undefined) continue
246
+ const kinds = endpointTypesOf(raw)
247
+ const kind = kinds.includes('image') ? 'image' : kinds.includes('video') ? 'video' : kinds[0]
248
+ if (kind !== undefined) out.set(id, { kind })
249
+ }
250
+ return out
251
+ }
252
+
253
+ /**
254
+ * Read provider profiles from `settings.describe()` output.
255
+ *
256
+ * A profile is any settings value shaped `{ providers: { <id>: { baseURL,
257
+ * apiKeyEnv, api, models, ... } } }` — that is the `llm-pi-ai` schema, but the
258
+ * reader does not hardcode the namespace so future adapters work too.
259
+ * @param descriptors - the settings descriptors.
260
+ * @returns Map provider id -> profile.
261
+ */
262
+ export function readProviderProfiles(descriptors) {
263
+ const profiles = new Map()
264
+ for (const descriptor of Array.isArray(descriptors) ? descriptors : []) {
265
+ const value = descriptor?.value
266
+ if (value === null || typeof value !== 'object') continue
267
+ const providers = value.providers
268
+ if (providers === null || typeof providers !== 'object' || Array.isArray(providers)) continue
269
+ for (const [id, raw] of Object.entries(providers)) {
270
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue
271
+ const baseURL = str(raw.baseURL)
272
+ if (baseURL === undefined) continue
273
+ const models = new Map()
274
+ for (const model of Array.isArray(raw.models) ? raw.models : []) {
275
+ if (model === null || typeof model !== 'object') continue
276
+ const modelId = str(model.id)
277
+ if (modelId !== undefined) models.set(modelId, model)
278
+ }
279
+ const headers = {}
280
+ if (raw.headers !== null && typeof raw.headers === 'object' && !Array.isArray(raw.headers)) {
281
+ for (const [name, headerValue] of Object.entries(raw.headers)) {
282
+ if (typeof headerValue === 'string') headers[name] = headerValue
283
+ }
284
+ }
285
+ const profile = {
286
+ id,
287
+ namespace: typeof descriptor.ns === 'string' ? descriptor.ns : undefined,
288
+ api: str(raw.api) ?? 'openai-completions',
289
+ baseURL,
290
+ apiKeyEnv: str(raw.apiKeyEnv),
291
+ headers,
292
+ defaultInput: normalizeModalities(raw.defaultInput),
293
+ defaultContextWindow: positive(raw.defaultContextWindow),
294
+ models,
295
+ }
296
+ const previous = profiles.get(id)
297
+ // A namespace that actually carries a model list wins over an empty shell.
298
+ if (previous === undefined || models.size >= previous.models.size) profiles.set(id, profile)
299
+ }
300
+ }
301
+ return profiles
302
+ }
303
+
304
+ /** Stable fingerprint of the routing facts that affect a probe. */
305
+ export function profileSignature(profiles) {
306
+ const parts = []
307
+ for (const profile of [...profiles.values()].sort((left, right) => left.id.localeCompare(right.id))) {
308
+ parts.push([
309
+ profile.id,
310
+ profile.api ?? '',
311
+ profile.baseURL ?? '',
312
+ profile.apiKeyEnv ?? '',
313
+ JSON.stringify(profile.headers ?? {}),
314
+ [...profile.models.keys()].sort().join(','),
315
+ ].join('|'))
316
+ }
317
+ return parts.join('\n')
318
+ }
319
+
320
+ /** Absolute listing URL candidates for one base URL. */
321
+ export function listingUrls(baseURL) {
322
+ const base = String(baseURL).replace(/\/+$/, '')
323
+ if (!/^https?:\/\//i.test(base)) return []
324
+ const urls = [`${base}/models`]
325
+ if (!/\/v\d+$/i.test(base)) urls.push(`${base}/v1/models`)
326
+ return urls
327
+ }
328
+
329
+ /** One JSON GET with a timeout, redirect refusal and a body cap. */
330
+ async function getJson(fetchImpl, url, headers, timeoutMs) {
331
+ const response = await fetchImpl(url, {
332
+ method: 'GET',
333
+ headers,
334
+ redirect: 'error',
335
+ signal: AbortSignal.timeout(timeoutMs),
336
+ })
337
+ const text = await response.text()
338
+ if (text.length > MAX_BODY_BYTES) throw new Error('response body is too large')
339
+ let body
340
+ try {
341
+ body = JSON.parse(text)
342
+ } catch (_error) {
343
+ throw new Error(`not JSON (HTTP ${response.status})`)
344
+ }
345
+ return { status: response.status, body }
346
+ }
347
+
348
+ /**
349
+ * A cached, best-effort probe over every configured provider route.
350
+ *
351
+ * @param options.settings - Host settings service (`describe()`).
352
+ * @param options.credentials - optional credentials service (`resolve(ref)`).
353
+ * @param options.logger - optional logger; only warnings, never payloads.
354
+ * @param options.fetch - fetch implementation (injectable for tests).
355
+ * @param options.now - clock (injectable for tests).
356
+ * @param options.ttlMs - probe cache lifetime.
357
+ * @param options.timeoutMs - per-request timeout.
358
+ */
359
+ export function createProviderProbe(options = {}) {
360
+ const settings = options.settings
361
+ const credentials = options.credentials
362
+ const logger = options.logger ?? console
363
+ const fetchImpl = typeof options.fetch === 'function' ? options.fetch : fetch
364
+ const now = typeof options.now === 'function' ? options.now : Date.now
365
+ const ttlMs = Number.isFinite(options.ttlMs) && options.ttlMs >= 0 ? options.ttlMs : DEFAULT_PROBE_TTL_MS
366
+ const timeoutMs =
367
+ Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
368
+ ? options.timeoutMs
369
+ : DEFAULT_PROBE_TIMEOUT_MS
370
+
371
+ let cached = null
372
+ let cachedAt = -Infinity
373
+ let inflight = null
374
+ let generation = 0
375
+ let lastSignature = ''
376
+ let profilesCache = null
377
+
378
+ const readProfiles = () => {
379
+ if (typeof settings?.describe !== 'function') return new Map()
380
+ try {
381
+ return readProviderProfiles(settings.describe())
382
+ } catch (error) {
383
+ if (typeof logger.warn === 'function') logger.warn('model-visibility: could not read provider settings', error)
384
+ return new Map()
385
+ }
386
+ }
387
+
388
+ /**
389
+ * The last known provider profiles, without probing.
390
+ *
391
+ * The request-side image gate needs a synchronous answer while the adapter
392
+ * resolves a model; re-reading `settings.describe()` for every model would be
393
+ * wasteful, so the map is cached until a settings or route change.
394
+ * @returns Map provider id -> profile.
395
+ */
396
+ const profiles = () => {
397
+ if (profilesCache === null) profilesCache = readProfiles()
398
+ return profilesCache
399
+ }
400
+
401
+ /** The last completed probe snapshot, without starting a new sweep. */
402
+ const peek = () => cached
403
+
404
+ /** Probe one provider route; never throws. */
405
+ const probeProvider = async (profile) => {
406
+ const diagnostics = {
407
+ id: profile.id,
408
+ status: 'skipped',
409
+ models: 0,
410
+ capabilities: 0,
411
+ pricing: 'skipped',
412
+ }
413
+ try {
414
+ diagnostics.host = new URL(profile.baseURL).host
415
+ } catch (_error) {
416
+ diagnostics.status = 'skipped'
417
+ diagnostics.message = 'invalid base URL'
418
+ return { entries: new Map(), diagnostics }
419
+ }
420
+ if (profile.apiKeyEnv === undefined) {
421
+ diagnostics.message = 'no credential reference'
422
+ return { entries: new Map(), diagnostics }
423
+ }
424
+ if (credentials === undefined || typeof credentials.resolve !== 'function') {
425
+ diagnostics.message = 'credential service unavailable'
426
+ return { entries: new Map(), diagnostics }
427
+ }
428
+ let key
429
+ try {
430
+ const resolved = await credentials.resolve(profile.apiKeyEnv)
431
+ key = typeof resolved?.value === 'string' && resolved.value !== '' ? resolved.value : undefined
432
+ } catch (_error) {
433
+ diagnostics.message = 'credential could not be resolved'
434
+ return { entries: new Map(), diagnostics }
435
+ }
436
+ if (key === undefined) {
437
+ diagnostics.status = 'unauthorized'
438
+ diagnostics.message = 'credential is not configured'
439
+ return { entries: new Map(), diagnostics }
440
+ }
441
+
442
+ const headers = {
443
+ accept: 'application/json',
444
+ 'user-agent': 'dsh-model-visibility/1.4',
445
+ ...profile.headers,
446
+ }
447
+ if (profile.api === 'anthropic-messages') {
448
+ headers['anthropic-version'] ??= '2023-06-01'
449
+ headers['x-api-key'] ??= key
450
+ } else {
451
+ headers.authorization ??= `Bearer ${key}`
452
+ }
453
+
454
+ let body
455
+ let lastError
456
+ for (const url of listingUrls(profile.baseURL)) {
457
+ try {
458
+ const response = await getJson(fetchImpl, url, headers, timeoutMs)
459
+ if (response.status === 401 || response.status === 403) {
460
+ diagnostics.status = 'unauthorized'
461
+ diagnostics.message = `HTTP ${response.status}`
462
+ return { entries: new Map(), diagnostics }
463
+ }
464
+ if (response.status < 200 || response.status >= 300) {
465
+ lastError = new Error(`HTTP ${response.status}`)
466
+ continue
467
+ }
468
+ body = response.body
469
+ break
470
+ } catch (error) {
471
+ lastError = error
472
+ }
473
+ }
474
+ if (body === undefined) {
475
+ diagnostics.status = 'failed'
476
+ diagnostics.message = lastError instanceof Error ? lastError.message : String(lastError)
477
+ return { entries: new Map(), diagnostics }
478
+ }
479
+
480
+ const entries = parseModelListing(body)
481
+ diagnostics.status = 'ok'
482
+ diagnostics.models = entries.size
483
+ const capabilityKeys = ['inputModalities', 'outputModalities', 'kind', 'reasoning', 'contextWindow']
484
+ diagnostics.capabilities = [...entries.values()].filter((entry) =>
485
+ capabilityKeys.some((field) => entry[field] !== undefined),
486
+ ).length
487
+
488
+ // New API exposes model kind through its public pricing endpoint. It is
489
+ // frequently disabled (403); that is a normal outcome, not a failure.
490
+ try {
491
+ const url = new URL(profile.baseURL)
492
+ const response = await getJson(fetchImpl, `${url.origin}/api/pricing`, headers, timeoutMs)
493
+ if (response.status === 403 || response.status === 404) {
494
+ diagnostics.pricing = 'disabled'
495
+ } else if (response.status >= 200 && response.status < 300) {
496
+ const pricing = parsePricing(response.body)
497
+ for (const [id, info] of pricing) {
498
+ const entry = entries.get(id) ?? { id }
499
+ if (entry.kind === undefined) entry.kind = info.kind
500
+ entries.set(id, entry)
501
+ }
502
+ diagnostics.pricing = 'ok'
503
+ } else {
504
+ diagnostics.pricing = 'failed'
505
+ }
506
+ } catch (_error) {
507
+ diagnostics.pricing = 'failed'
508
+ }
509
+
510
+ return { entries, diagnostics }
511
+ }
512
+
513
+ const sweep = async () => {
514
+ const profiles = readProfiles()
515
+ profilesCache = profiles
516
+ const initialSignature = profileSignature(profiles)
517
+ lastSignature = initialSignature
518
+ const discovered = new Map()
519
+ const diagnostics = []
520
+ const routes = [...profiles.values()]
521
+ await Promise.all(
522
+ routes.map(async (profile) => {
523
+ const result = await probeProvider(profile)
524
+ discovered.set(profile.id, result.entries)
525
+ diagnostics.push(result.diagnostics)
526
+ }),
527
+ )
528
+ diagnostics.sort((left, right) => String(left.id).localeCompare(String(right.id)))
529
+ // Re-read the profiles after the sweep: a settings write during the network
530
+ // calls may have changed model-level declarations, and those must reach the
531
+ // merge without waiting for another TTL.
532
+ const latestProfiles = readProfiles()
533
+ profilesCache = latestProfiles
534
+ const latestSignature = profileSignature(latestProfiles)
535
+ lastSignature = latestSignature
536
+ // A routing change during the sweep invalidates the entries we just
537
+ // collected; return them for this caller but do not cache them.
538
+ if (latestSignature !== initialSignature) generation += 1
539
+ return { profiles: latestProfiles, discovered, diagnostics, probedAt: now() }
540
+ }
541
+
542
+ const read = (request = {}) => {
543
+ const fresh = request !== null && typeof request === 'object' && request.fresh === true
544
+ const stamp = now()
545
+ if (!fresh && cached !== null && stamp - cachedAt < ttlMs) return Promise.resolve(cached)
546
+ if (inflight !== null) return inflight
547
+ const startedAt = generation
548
+ const run = sweep()
549
+ .then((value) => {
550
+ if (generation === startedAt) {
551
+ cached = value
552
+ cachedAt = now()
553
+ }
554
+ return value
555
+ })
556
+ .catch((error) => {
557
+ if (typeof logger.warn === 'function') logger.warn('model-visibility: provider probe failed', error)
558
+ return { profiles: readProfiles(), discovered: new Map(), diagnostics: [], probedAt: now() }
559
+ })
560
+ .finally(() => {
561
+ // `invalidate()` may have started a newer sweep while this one ran;
562
+ // only clear the slot this promise owns.
563
+ if (inflight === run) inflight = null
564
+ })
565
+ inflight = run
566
+ return run
567
+ }
568
+
569
+ /** A settings write may change routes; only drop the cache when it did. */
570
+ const noteSettingsChanged = () => {
571
+ const profiles = readProfiles()
572
+ profilesCache = profiles
573
+ const signature = profileSignature(profiles)
574
+ if (signature !== lastSignature) {
575
+ lastSignature = signature
576
+ cached = null
577
+ cachedAt = -Infinity
578
+ generation += 1
579
+ return
580
+ }
581
+ // Model-level declarations (input / context / reasoning) feed the merge
582
+ // even when the routing facts did not change. Refresh them without paying
583
+ // for another network sweep: hiding a model must never re-probe 8 relays.
584
+ if (cached !== null) cached = { ...cached, profiles }
585
+ }
586
+
587
+ const invalidate = () => {
588
+ generation += 1
589
+ cached = null
590
+ cachedAt = -Infinity
591
+ inflight = null
592
+ profilesCache = null
593
+ }
594
+
595
+ return { read, invalidate, noteSettingsChanged, profiles, peek }
596
+ }