@tzyc/dsh-model-visibility 0.0.0-stage → 1.9.1
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/LICENSE +21 -0
- package/README.md +367 -2
- package/client.js +2258 -0
- package/cordis.patch.yml +7 -0
- package/docs/capability-tags.png +0 -0
- package/docs/icon-180.png +0 -0
- package/docs/settings-page.png +0 -0
- package/icon.svg +9 -0
- package/index.js +476 -0
- package/lib/capability-catalog.js +137 -0
- package/lib/capability-corrections.js +65 -0
- package/lib/image-input-gate.js +113 -0
- package/lib/model-catalog.js +670 -0
- package/lib/model-info.js +786 -0
- package/lib/model-overrides.js +262 -0
- package/lib/provider-probe.js +596 -0
- package/lib/provider-write.js +175 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +64 -6
- package/screenshots.json +4 -0
|
@@ -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
|
+
}
|