@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
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# dsh-model-visibility: one Host row owns the `model-visibility` settings
|
|
2
|
+
# namespace (the persisted list of hidden models); package.json's dsh.client
|
|
3
|
+
# declares the browser half, which binds to this same row id.
|
|
4
|
+
- insert:
|
|
5
|
+
- id: model-visibility
|
|
6
|
+
name: '@tzyc/dsh-model-visibility'
|
|
7
|
+
config: {}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/icon.svg
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Model visibility">
|
|
2
|
+
<rect width="64" height="64" rx="14" fill="#247bbf"/>
|
|
3
|
+
<rect x="10" y="15" width="44" height="11" rx="5.5" fill="#ffffff" fill-opacity="0.28"/>
|
|
4
|
+
<rect x="15" y="19" width="14" height="3" rx="1.5" fill="#ffffff"/>
|
|
5
|
+
<circle cx="46" cy="20.5" r="4.5" fill="#ffffff"/>
|
|
6
|
+
<rect x="10" y="38" width="44" height="11" rx="5.5" fill="#ffffff" fill-opacity="0.16"/>
|
|
7
|
+
<rect x="35" y="42" width="14" height="3" rx="1.5" fill="#ffffff" fill-opacity="0.55"/>
|
|
8
|
+
<circle cx="18" cy="43.5" r="4.5" fill="#ffffff" fill-opacity="0.55"/>
|
|
9
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of the model-visibility plugin.
|
|
3
|
+
*
|
|
4
|
+
* Two jobs:
|
|
5
|
+
*
|
|
6
|
+
* 1. Own the `model-visibility` settings namespace: `hidden` is a volatile
|
|
7
|
+
* Config field, so the Host settings document persists the browser's choices
|
|
8
|
+
* and `settings.describe` projects them back to the Client. The generated
|
|
9
|
+
* configuration page is disabled because the plugin's own settings section
|
|
10
|
+
* is the editing surface.
|
|
11
|
+
* 2. Keep a shadow copy of that list outside the profile patch layer
|
|
12
|
+
* (`$DSH_HOME/dsh-model-visibility/hidden.json`), because the patch layer is
|
|
13
|
+
* rebuilt from scratch in several ordinary situations — a profile repair,
|
|
14
|
+
* a plugin reinstall, or any write whose value equals the inherited default
|
|
15
|
+
* (the settings writer then deletes the row). Without a shadow copy the row
|
|
16
|
+
* silently disappears and every model becomes visible again after the next
|
|
17
|
+
* reload. The schema default is seeded from that file, so a rebuilt patch
|
|
18
|
+
* layer restores the last known list instead of an empty one.
|
|
19
|
+
*
|
|
20
|
+
* Each entry is an opaque `provider/model` key; the Client never parses it.
|
|
21
|
+
*
|
|
22
|
+
* @module index.js
|
|
23
|
+
*/
|
|
24
|
+
import fs from 'node:fs'
|
|
25
|
+
import os from 'node:os'
|
|
26
|
+
import path from 'node:path'
|
|
27
|
+
import z from '@deepseek-ai/schemastery'
|
|
28
|
+
|
|
29
|
+
import { createModelInfoService } from './lib/model-info.js'
|
|
30
|
+
import { createOverrideStore } from './lib/model-overrides.js'
|
|
31
|
+
import { createProviderProbe, readProviderProfiles } from './lib/provider-probe.js'
|
|
32
|
+
import { stripWrittenFields, writeCapabilities } from './lib/provider-write.js'
|
|
33
|
+
import { installImageInputGate } from './lib/image-input-gate.js'
|
|
34
|
+
|
|
35
|
+
/** Settings namespace this entry owns; also the loader row id. */
|
|
36
|
+
const ENTRY = 'model-visibility'
|
|
37
|
+
/** Config field carrying the hidden model keys. */
|
|
38
|
+
const FIELD = 'hidden'
|
|
39
|
+
/** Shadow store location below `$DSH_HOME`. */
|
|
40
|
+
const STORE_DIR = 'dsh-model-visibility'
|
|
41
|
+
const STORE_FILE = 'hidden.json'
|
|
42
|
+
/** Authenticated same-origin route prefix used by the settings page. */
|
|
43
|
+
const API = '/api/dsh-model-visibility'
|
|
44
|
+
|
|
45
|
+
/** Absolute path of the shadow copy. */
|
|
46
|
+
function storeFile() {
|
|
47
|
+
const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
|
48
|
+
return path.join(home, STORE_DIR, STORE_FILE)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Keep only string entries, so a hand-edited file can never break the schema. */
|
|
52
|
+
function asKeyList(value) {
|
|
53
|
+
if (!Array.isArray(value)) return []
|
|
54
|
+
return value.filter((key) => typeof key === 'string')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Read the shadow copy; a missing or unreadable file simply yields an empty list. */
|
|
58
|
+
function readStored() {
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(fs.readFileSync(storeFile(), 'utf8'))
|
|
61
|
+
return asKeyList(Array.isArray(parsed) ? parsed : parsed?.[FIELD])
|
|
62
|
+
} catch (_error) {
|
|
63
|
+
return []
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Replace the shadow copy atomically; a failure must never break the plugin. */
|
|
68
|
+
function writeStored(list) {
|
|
69
|
+
const file = storeFile()
|
|
70
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
71
|
+
const temp = `${file}.${process.pid}.tmp`
|
|
72
|
+
fs.writeFileSync(temp, `${JSON.stringify({ [FIELD]: list }, null, 2)}\n`)
|
|
73
|
+
fs.renameSync(temp, file)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Restored default: a rebuilt settings document keeps the last hidden list
|
|
78
|
+
* instead of falling back to "everything visible".
|
|
79
|
+
*/
|
|
80
|
+
const RESTORED = readStored()
|
|
81
|
+
|
|
82
|
+
/** Model keys the composer must stop offering, in the order they were hidden. */
|
|
83
|
+
export const Config = z.object({
|
|
84
|
+
hidden: z.array(z.string()).default(RESTORED).volatile(),
|
|
85
|
+
/** Probe relay endpoints for capability metadata (set MODEL_VISIBILITY_PROBE=0 to disable). */
|
|
86
|
+
probe: z.boolean().default(true),
|
|
87
|
+
/**
|
|
88
|
+
* Admit image input on relay models the page displays as multimodal. DSH
|
|
89
|
+
* builds the adapter model from the provider's `defaultInput` (usually
|
|
90
|
+
* text) when the route leaves `input` empty, so a relay model that really
|
|
91
|
+
* accepts images is rejected by both the session check and the serializer.
|
|
92
|
+
* Volatile so flipping the switch reaches the running Host without a reload.
|
|
93
|
+
*/
|
|
94
|
+
applyInput: z.boolean().default(true).volatile(),
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Disable the generated configuration page and keep the shadow copy current.
|
|
99
|
+
* @param ctx - plugin context.
|
|
100
|
+
* @param config - resolved plugin config; `hidden` is a stable volatile reference.
|
|
101
|
+
*/
|
|
102
|
+
export function apply(ctx, config) {
|
|
103
|
+
ctx.inject(['settings'], (child) => {
|
|
104
|
+
child.effect(() => child.settings.configure({ auto: false }))
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const logger = ctx.logger ?? console
|
|
108
|
+
|
|
109
|
+
// Manual capability overrides live in their own file: they are structured,
|
|
110
|
+
// change independently of the hidden list, and the request-side image gate
|
|
111
|
+
// needs a synchronous read. See `lib/model-overrides.js`.
|
|
112
|
+
const overrides = createOverrideStore({ logger })
|
|
113
|
+
|
|
114
|
+
/** Current hidden keys, read through the stable volatile reference. */
|
|
115
|
+
const liveList = () => {
|
|
116
|
+
const value = config?.[FIELD]
|
|
117
|
+
const current = value !== null && typeof value === 'object' && typeof value.get === 'function'
|
|
118
|
+
? value.get()
|
|
119
|
+
: value
|
|
120
|
+
return asKeyList(current)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Read one config field, tolerating both a volatile reference and a value. */
|
|
124
|
+
const liveConfig = (field, fallback) => {
|
|
125
|
+
const value = config?.[field]
|
|
126
|
+
const current = value !== null && typeof value === 'object' && typeof value.get === 'function'
|
|
127
|
+
? value.get()
|
|
128
|
+
: value
|
|
129
|
+
return current === undefined ? fallback : current
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let written
|
|
133
|
+
const persist = () => {
|
|
134
|
+
const list = liveList()
|
|
135
|
+
const signature = list.join('\u0000')
|
|
136
|
+
if (signature === written) return
|
|
137
|
+
try {
|
|
138
|
+
writeStored(list)
|
|
139
|
+
written = signature
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (typeof logger.warn === 'function') {
|
|
142
|
+
logger.warn('model-visibility: could not write the shadow copy')
|
|
143
|
+
logger.warn(error)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
ctx.effect(() => {
|
|
149
|
+
const disposers = []
|
|
150
|
+
/**
|
|
151
|
+
* A volatile-only settings write updates the reference in place and emits
|
|
152
|
+
* `loader/volatile-update`; the settings document event covers the same
|
|
153
|
+
* change when the loader reports it differently.
|
|
154
|
+
*/
|
|
155
|
+
const onConfigChanged = (paths) => {
|
|
156
|
+
if (
|
|
157
|
+
Array.isArray(paths) &&
|
|
158
|
+
!paths.some((item) => Array.isArray(item) && (item[0] === FIELD || item[0] === 'applyInput'))
|
|
159
|
+
) {
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
persist()
|
|
163
|
+
}
|
|
164
|
+
if (typeof ctx.on === 'function') {
|
|
165
|
+
disposers.push(
|
|
166
|
+
ctx.on('loader/volatile-update', (paths) => {
|
|
167
|
+
onConfigChanged(paths)
|
|
168
|
+
}),
|
|
169
|
+
)
|
|
170
|
+
disposers.push(
|
|
171
|
+
ctx.on('settings/document-updated', (ns) => {
|
|
172
|
+
if (ns === ENTRY) onConfigChanged()
|
|
173
|
+
}),
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
return () => {
|
|
177
|
+
for (const dispose of disposers) {
|
|
178
|
+
try {
|
|
179
|
+
if (typeof dispose === 'function') dispose()
|
|
180
|
+
} catch (_error) {
|
|
181
|
+
// A listener may already be gone during teardown.
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}, 'model-visibility: shadow copy')
|
|
186
|
+
|
|
187
|
+
ctx.effect(() => {
|
|
188
|
+
persist()
|
|
189
|
+
return () => persist()
|
|
190
|
+
}, 'model-visibility: shadow baseline')
|
|
191
|
+
|
|
192
|
+
// The capability catalog is a read-only projection of the live LLM registry,
|
|
193
|
+
// enriched with the provider profile (settings) and the relay's own model
|
|
194
|
+
// listing. It is served over an authenticated same-origin route because the
|
|
195
|
+
// shipped `session.modelCatalog` projection drops context windows and
|
|
196
|
+
// modalities, and a custom relay reports `input: []` for almost everything.
|
|
197
|
+
ctx.inject(['llm', 'connection'], (scope) => {
|
|
198
|
+
const connection = scope.connection
|
|
199
|
+
if (scope.llm === undefined) {
|
|
200
|
+
if (typeof logger.warn === 'function') {
|
|
201
|
+
logger.warn('model-visibility: llm service unavailable; capability route not registered')
|
|
202
|
+
}
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
if (connection === undefined || typeof connection.fetch?.register !== 'function') {
|
|
206
|
+
if (typeof logger.warn === 'function') {
|
|
207
|
+
logger.warn('model-visibility: connection.fetch unavailable; capability route not registered')
|
|
208
|
+
}
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Optional services: without them the page still renders adapter + catalog
|
|
213
|
+
// facts, it just cannot probe relay endpoints.
|
|
214
|
+
const settings = typeof scope.get === 'function' ? scope.get('settings') : undefined
|
|
215
|
+
const credentials = typeof scope.get === 'function' ? scope.get('credentials') : undefined
|
|
216
|
+
const probeEnabled = config?.probe !== false && process.env.MODEL_VISIBILITY_PROBE !== '0'
|
|
217
|
+
const probe =
|
|
218
|
+
probeEnabled && settings !== undefined
|
|
219
|
+
? createProviderProbe({ settings, credentials, logger })
|
|
220
|
+
: null
|
|
221
|
+
|
|
222
|
+
// Even with the relay probe disabled, the request-side gate still needs the
|
|
223
|
+
// provider declarations. Reading them is local and synchronous; cache the
|
|
224
|
+
// map per settings generation because the gate asks once per model.
|
|
225
|
+
let fallbackProfiles = null
|
|
226
|
+
const profilesForGate = () => {
|
|
227
|
+
if (fallbackProfiles !== null) return fallbackProfiles
|
|
228
|
+
if (typeof settings?.describe !== 'function') return new Map()
|
|
229
|
+
try {
|
|
230
|
+
fallbackProfiles = readProviderProfiles(settings.describe())
|
|
231
|
+
} catch (_error) {
|
|
232
|
+
fallbackProfiles = new Map()
|
|
233
|
+
}
|
|
234
|
+
return fallbackProfiles
|
|
235
|
+
}
|
|
236
|
+
const clearFallbackProfiles = () => {
|
|
237
|
+
fallbackProfiles = null
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const service = createModelInfoService(scope.llm, {
|
|
241
|
+
enrichment: probe === null ? undefined : ({ fresh }) => probe.read({ fresh }),
|
|
242
|
+
overrides,
|
|
243
|
+
// Synchronous evidence for the request-side image gate: provider profiles
|
|
244
|
+
// from the settings document plus the last completed relay probe.
|
|
245
|
+
context: () => ({
|
|
246
|
+
profiles: probe === null ? profilesForGate() : probe.profiles(),
|
|
247
|
+
discovered: probe?.peek()?.discovered,
|
|
248
|
+
overrides,
|
|
249
|
+
}),
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
// Make the relay adapter admit images for models the page displays as
|
|
253
|
+
// multimodal. The patch is read-only: an explicit provider declaration
|
|
254
|
+
// still wins, and the settings switch can turn the behaviour off.
|
|
255
|
+
const imageInput = installImageInputGate(scope.llm, {
|
|
256
|
+
allow: (provider, model) =>
|
|
257
|
+
liveConfig('applyInput', true) !== false && service.imageInputAllowed(provider, model),
|
|
258
|
+
warn: (error) => {
|
|
259
|
+
if (typeof logger.warn === 'function') {
|
|
260
|
+
logger.warn('model-visibility: image input gate failed')
|
|
261
|
+
logger.warn(error)
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
})
|
|
265
|
+
scope.effect(() => () => imageInput.dispose(), 'model-visibility: image input gate')
|
|
266
|
+
|
|
267
|
+
/** Answer with JSON; the route is diagnostics-friendly on purpose. */
|
|
268
|
+
const json = (value, status = 200) =>
|
|
269
|
+
new Response(JSON.stringify(value), {
|
|
270
|
+
status,
|
|
271
|
+
headers: {
|
|
272
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
273
|
+
'Cache-Control': 'no-store',
|
|
274
|
+
},
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
scope.effect(
|
|
278
|
+
() =>
|
|
279
|
+
connection.fetch.register({
|
|
280
|
+
path: `${API}/models`,
|
|
281
|
+
methods: ['GET'],
|
|
282
|
+
requestBody: 'buffered',
|
|
283
|
+
async fetch(request) {
|
|
284
|
+
let fresh = false
|
|
285
|
+
let reload = false
|
|
286
|
+
try {
|
|
287
|
+
const params = new URL(request.url).searchParams
|
|
288
|
+
fresh = params.get('fresh') !== null
|
|
289
|
+
reload = params.get('reload') !== null
|
|
290
|
+
} catch (_error) {
|
|
291
|
+
// A malformed URL simply means "use the cached read".
|
|
292
|
+
}
|
|
293
|
+
// A hard reload is the page's "re-fetch capabilities" action: drop
|
|
294
|
+
// every cache and any sweep already in flight, then read again. A
|
|
295
|
+
// plain `fresh` read bypasses TTLs but still joins an in-flight
|
|
296
|
+
// sweep, which can be exactly the stale read the user is trying to
|
|
297
|
+
// escape.
|
|
298
|
+
if (reload) {
|
|
299
|
+
service.invalidate()
|
|
300
|
+
probe?.invalidate()
|
|
301
|
+
clearFallbackProfiles()
|
|
302
|
+
overrides.invalidate()
|
|
303
|
+
imageInput.refresh()
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
return json({ ok: true, value: await service.read({ fresh: fresh || reload }) })
|
|
307
|
+
} catch (error) {
|
|
308
|
+
if (typeof logger.warn === 'function') {
|
|
309
|
+
logger.warn('model-visibility: capability probe failed')
|
|
310
|
+
logger.warn(error)
|
|
311
|
+
}
|
|
312
|
+
return json(
|
|
313
|
+
{
|
|
314
|
+
ok: false,
|
|
315
|
+
error: {
|
|
316
|
+
code: 'model-info/failed',
|
|
317
|
+
message: error instanceof Error ? error.message : String(error),
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
500,
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
}),
|
|
325
|
+
'model-visibility: capability route',
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
// Manual capability overrides: the page reads the current entries and
|
|
329
|
+
// writes one model at a time. Both calls are same-origin authenticated by
|
|
330
|
+
// the connection layer, like the capability route above.
|
|
331
|
+
scope.effect(
|
|
332
|
+
() =>
|
|
333
|
+
connection.fetch.register({
|
|
334
|
+
path: `${API}/overrides`,
|
|
335
|
+
methods: ['GET', 'POST'],
|
|
336
|
+
requestBody: 'buffered',
|
|
337
|
+
async fetch(request) {
|
|
338
|
+
const method = String(request.method || 'GET').toUpperCase()
|
|
339
|
+
if (method === 'GET') {
|
|
340
|
+
try {
|
|
341
|
+
return json({ ok: true, value: { overrides: overrides.list() } })
|
|
342
|
+
} catch (error) {
|
|
343
|
+
if (typeof logger.warn === 'function') {
|
|
344
|
+
logger.warn('model-visibility: could not read model overrides')
|
|
345
|
+
logger.warn(error)
|
|
346
|
+
}
|
|
347
|
+
return json(
|
|
348
|
+
{
|
|
349
|
+
ok: false,
|
|
350
|
+
error: {
|
|
351
|
+
code: 'overrides/failed',
|
|
352
|
+
message: error instanceof Error ? error.message : String(error),
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
500,
|
|
356
|
+
)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (method !== 'POST') {
|
|
360
|
+
return json(
|
|
361
|
+
{ ok: false, error: { code: 'overrides/method', message: 'GET or POST' } },
|
|
362
|
+
405,
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
let body
|
|
366
|
+
try {
|
|
367
|
+
body = await request.json()
|
|
368
|
+
} catch (_error) {
|
|
369
|
+
return json(
|
|
370
|
+
{
|
|
371
|
+
ok: false,
|
|
372
|
+
error: { code: 'overrides/body', message: 'expected a JSON override entry' },
|
|
373
|
+
},
|
|
374
|
+
400,
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
// What the provider schema can express is mirrored into the
|
|
378
|
+
// provider's own settings, so the composer and the adapter see the
|
|
379
|
+
// same facts as this page. A refusal is not an error: the local
|
|
380
|
+
// override still applies, and the response explains the reason.
|
|
381
|
+
let provider = { written: false, fields: [], reason: 'skipped' }
|
|
382
|
+
try {
|
|
383
|
+
if (body !== null && typeof body === 'object' && body.remove === true) {
|
|
384
|
+
overrides.remove(
|
|
385
|
+
typeof body.provider === 'string' ? body.provider : '',
|
|
386
|
+
typeof body.model === 'string' ? body.model : '',
|
|
387
|
+
)
|
|
388
|
+
} else {
|
|
389
|
+
const saved = overrides.upsert(body)
|
|
390
|
+
if (saved !== undefined && body.applyToProvider !== false) {
|
|
391
|
+
provider = await writeCapabilities(settings, saved)
|
|
392
|
+
if (provider.written) {
|
|
393
|
+
const remaining = stripWrittenFields(saved, provider.fields)
|
|
394
|
+
const local = Object.keys(remaining).filter(
|
|
395
|
+
(key) => key !== 'provider' && key !== 'model',
|
|
396
|
+
)
|
|
397
|
+
if (local.length === 0) {
|
|
398
|
+
overrides.remove(saved.provider, saved.model)
|
|
399
|
+
} else {
|
|
400
|
+
overrides.upsert(remaining)
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
} catch (error) {
|
|
406
|
+
if (typeof logger.warn === 'function') {
|
|
407
|
+
logger.warn('model-visibility: could not save a model override')
|
|
408
|
+
logger.warn(error)
|
|
409
|
+
}
|
|
410
|
+
return json(
|
|
411
|
+
{
|
|
412
|
+
ok: false,
|
|
413
|
+
error: {
|
|
414
|
+
code: 'overrides/failed',
|
|
415
|
+
message: error instanceof Error ? error.message : String(error),
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
500,
|
|
419
|
+
)
|
|
420
|
+
}
|
|
421
|
+
// The new entry outranks every cached capability fact. A provider
|
|
422
|
+
// write also changed the settings document: refresh the profile
|
|
423
|
+
// cache so the page immediately shows the declared values instead
|
|
424
|
+
// of the stale ones (no network sweep is needed for that).
|
|
425
|
+
service.invalidate()
|
|
426
|
+
imageInput.refresh()
|
|
427
|
+
if (provider.written) {
|
|
428
|
+
clearFallbackProfiles()
|
|
429
|
+
probe?.noteSettingsChanged()
|
|
430
|
+
}
|
|
431
|
+
return json({ ok: true, value: { overrides: overrides.list(), provider } })
|
|
432
|
+
},
|
|
433
|
+
}),
|
|
434
|
+
'model-visibility: override route',
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
// A route/credential change invalidates both the capability cache and (when
|
|
438
|
+
// it actually changed routing facts) the relay-probe cache. Ordinary
|
|
439
|
+
// settings writes — hiding one model — only invalidate the capability
|
|
440
|
+
// snapshot, so a toggle never fans out into a network sweep.
|
|
441
|
+
scope.effect(() => {
|
|
442
|
+
const offs = []
|
|
443
|
+
const invalidateAll = () => {
|
|
444
|
+
service.invalidate()
|
|
445
|
+
probe?.invalidate()
|
|
446
|
+
clearFallbackProfiles()
|
|
447
|
+
imageInput.refresh()
|
|
448
|
+
}
|
|
449
|
+
const noteSettings = () => {
|
|
450
|
+
service.invalidate()
|
|
451
|
+
probe?.noteSettingsChanged()
|
|
452
|
+
clearFallbackProfiles()
|
|
453
|
+
}
|
|
454
|
+
if (typeof scope.on === 'function') {
|
|
455
|
+
offs.push(scope.on('llm/adapters-updated', invalidateAll))
|
|
456
|
+
offs.push(scope.on('settings/document-updated', noteSettings))
|
|
457
|
+
for (const event of ['credentials/record-updated', 'credentials/reference-updated']) {
|
|
458
|
+
try {
|
|
459
|
+
offs.push(scope.on(event, invalidateAll))
|
|
460
|
+
} catch (_error) {
|
|
461
|
+
// Older hosts do not emit every credential event.
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return () => {
|
|
466
|
+
for (const off of offs) {
|
|
467
|
+
try {
|
|
468
|
+
if (typeof off === 'function') off()
|
|
469
|
+
} catch (_error) {
|
|
470
|
+
// The service may already be gone during teardown.
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}, 'model-visibility: capability cache')
|
|
475
|
+
})
|
|
476
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog matching and family heuristics.
|
|
3
|
+
*
|
|
4
|
+
* The adapter contract only carries `inputModalities`, and for a custom relay
|
|
5
|
+
* that field is usually the provider's `defaultInput` (text) rather than a fact
|
|
6
|
+
* about the model. This module fills that gap from two places, in order:
|
|
7
|
+
*
|
|
8
|
+
* 1. the pi-ai / models.dev snapshot in `model-catalog.js` (exact id match
|
|
9
|
+
* after normalizing aggregator prefixes and request suffixes),
|
|
10
|
+
* 2. conservative name rules for model families whose capabilities are common
|
|
11
|
+
* knowledge (Claude, GPT-4o+, Gemini, Grok 4+, VL/vision variants).
|
|
12
|
+
*
|
|
13
|
+
* Everything returned here is *inferred*: callers must label it as such so the
|
|
14
|
+
* page never presents a guess as an adapter declaration.
|
|
15
|
+
*
|
|
16
|
+
* @module lib/capability-catalog
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { CATALOG_GENERATED_AT, CATALOG_PI_AI_VERSION, MODEL_CATALOG } from './model-catalog.js'
|
|
20
|
+
|
|
21
|
+
export { CATALOG_GENERATED_AT, CATALOG_PI_AI_VERSION, MODEL_CATALOG }
|
|
22
|
+
|
|
23
|
+
/** Kinds the page knows how to label. */
|
|
24
|
+
export const MODEL_KINDS = Object.freeze([
|
|
25
|
+
'chat',
|
|
26
|
+
'image',
|
|
27
|
+
'video',
|
|
28
|
+
'embedding',
|
|
29
|
+
'rerank',
|
|
30
|
+
'audio',
|
|
31
|
+
'classifier',
|
|
32
|
+
'translation',
|
|
33
|
+
])
|
|
34
|
+
|
|
35
|
+
const IMAGE_WORDS = /(^|[-_./\s])(image|imagen|images|dall-?e|flux|sdxl|stable-?diffusion|midjourney|mj|seedream|ideogram|recraft|nano-?banana|photon|drawing|text2image|txt2img)([-_./\s]|$)/
|
|
36
|
+
const VIDEO_WORDS = /(^|[-_./\s])(video|sora|veo|kling|hailuo|seedance|pika|runway|vidu|jimeng|wan2|wanx|ltx|mochi|movie|text2video|txt2video)([-_./\s]|$)/
|
|
37
|
+
const EMBED_WORDS = /(^|[-_./\s])(embed|embedding|embeddings|bge|gte|e5|voyage|jina|nv-embed|text-embedding)([-_./\s]|$)/
|
|
38
|
+
const RERANK_WORDS = /(^|[-_./\s])(rerank|reranker|reranking)([-_./\s]|$)/
|
|
39
|
+
const AUDIO_WORDS = /(^|[-_./\s])(tts|speech|asr|whisper|transcribe|transcription|audio|voice|speech-to-text|text-to-speech)([-_./\s]|$)/
|
|
40
|
+
const CLASSIFIER_WORDS = /(^|[-_./\s])(guard|safety|moderation|classifier|censor|content-safety|prompt-guard)([-_./\s]|$)/
|
|
41
|
+
const TRANSLATION_WORDS = /(^|[-_./\s])(translate|translation|translator)([-_./\s]|$)/
|
|
42
|
+
|
|
43
|
+
const VISION_WORDS = /(^|[-_./\s])(vl|vision|visual|omni|multimodal|mm)([-_./\s]|$)|llava|pixtral|internvl|molmo|idefics|qwen[\d.]*-?vl|glm-[\d.]+v|kimi-.*-vl/
|
|
44
|
+
|
|
45
|
+
/** Strip aggregator namespaces, request suffixes and resolution variants. */
|
|
46
|
+
export function normalizeModelId(value) {
|
|
47
|
+
let id = String(value ?? '').trim().toLowerCase()
|
|
48
|
+
if (id === '') return ''
|
|
49
|
+
id = id.replace(/^~/, '')
|
|
50
|
+
// Providers may stack request variants (for example `:free:thinking`).
|
|
51
|
+
// Strip them one at a time so aliases remain stable without broad folding.
|
|
52
|
+
let previous
|
|
53
|
+
do {
|
|
54
|
+
previous = id
|
|
55
|
+
id = id.replace(/:(batch|free|extended|thinking|online|nitro)$/, '')
|
|
56
|
+
id = id.replace(/-(free|batch)$/, '')
|
|
57
|
+
} while (id !== previous)
|
|
58
|
+
const slash = id.lastIndexOf('/')
|
|
59
|
+
if (slash !== -1) id = id.slice(slash + 1)
|
|
60
|
+
id = id.replace(/^(global|us|eu|apac|jp|au|ca)\./, '')
|
|
61
|
+
id = id.replace(/^(anthropic|openai|google|xai|x-ai|meta|mistralai|moonshotai|minimax|nvidia|qwen|z-ai|zai|deepseek|bytedance-seed)\./, '')
|
|
62
|
+
return id
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Lookup keys for one model id, most specific first. */
|
|
66
|
+
export function modelIdVariants(value) {
|
|
67
|
+
const base = normalizeModelId(value)
|
|
68
|
+
if (base === '') return []
|
|
69
|
+
const variants = [base]
|
|
70
|
+
const withoutResolution = base.replace(/-(4k|2k|8k|hd|uhd)$/, '')
|
|
71
|
+
if (withoutResolution !== base) variants.push(withoutResolution)
|
|
72
|
+
const withoutSuffix = withoutResolution.replace(/-(latest|preview|new)$/, '')
|
|
73
|
+
if (!variants.includes(withoutSuffix)) variants.push(withoutSuffix)
|
|
74
|
+
const compact = base.replace(/[^a-z0-9]+/g, '')
|
|
75
|
+
if (compact !== '' && !variants.includes(compact)) variants.push(compact)
|
|
76
|
+
return variants
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Exact/alias index over the generated snapshot. */
|
|
80
|
+
const INDEX = (() => {
|
|
81
|
+
const index = new Map()
|
|
82
|
+
for (const [id, entry] of Object.entries(MODEL_CATALOG)) {
|
|
83
|
+
for (const variant of modelIdVariants(id)) {
|
|
84
|
+
if (!index.has(variant)) index.set(variant, { id, ...entry })
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return index
|
|
88
|
+
})()
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Find the snapshot entry for one model id.
|
|
92
|
+
* @param value - model id as any provider spells it.
|
|
93
|
+
* @returns `{ id, type, input?, output?, reasoning?, contextWindow?, maxTokens?, provider? }` or undefined.
|
|
94
|
+
*/
|
|
95
|
+
export function lookupCatalogModel(value) {
|
|
96
|
+
for (const variant of modelIdVariants(value)) {
|
|
97
|
+
const found = INDEX.get(variant)
|
|
98
|
+
if (found !== undefined) return found
|
|
99
|
+
}
|
|
100
|
+
return undefined
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Coarse kind from the id/name alone. */
|
|
104
|
+
export function inferModelKind(id, name = '') {
|
|
105
|
+
const text = `${String(id ?? '')} ${String(name ?? '')}`.toLowerCase()
|
|
106
|
+
if (text.trim() === '') return undefined
|
|
107
|
+
if (VIDEO_WORDS.test(text)) return 'video'
|
|
108
|
+
if (IMAGE_WORDS.test(text)) return 'image'
|
|
109
|
+
if (RERANK_WORDS.test(text)) return 'rerank'
|
|
110
|
+
if (EMBED_WORDS.test(text)) return 'embedding'
|
|
111
|
+
if (AUDIO_WORDS.test(text)) return 'audio'
|
|
112
|
+
if (CLASSIFIER_WORDS.test(text)) return 'classifier'
|
|
113
|
+
if (TRANSLATION_WORDS.test(text)) return 'translation'
|
|
114
|
+
return 'chat'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Modalities inferred from a chat model family; never claims text-only. */
|
|
118
|
+
export function inferInputModalities(id, name = '') {
|
|
119
|
+
const text = `${String(id ?? '')} ${String(name ?? '')}`.toLowerCase()
|
|
120
|
+
if (VISION_WORDS.test(text)) return ['text', 'image']
|
|
121
|
+
if (/claude|fable|opus|sonnet|haiku/.test(text)) return ['text', 'image']
|
|
122
|
+
if (/gpt-4o|gpt-4\.1|gpt-5|gpt-6|chatgpt/.test(text)) return ['text', 'image']
|
|
123
|
+
if (/gemini/.test(text)) return ['text', 'image']
|
|
124
|
+
if (/grok-[3-9]/.test(text)) return ['text', 'image']
|
|
125
|
+
if (/llama-4|llama-3\.2-vision/.test(text)) return ['text', 'image']
|
|
126
|
+
return undefined
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Kinds that produce assets instead of conversation turns. */
|
|
130
|
+
export function isGenerationKind(kind) {
|
|
131
|
+
return kind === 'image' || kind === 'video' || kind === 'audio'
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** True when the kind should hide reasoning/context/text tags. */
|
|
135
|
+
export function isNonChatKind(kind) {
|
|
136
|
+
return kind !== undefined && kind !== 'chat'
|
|
137
|
+
}
|