@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.
- 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,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manual capability overrides.
|
|
3
|
+
*
|
|
4
|
+
* Some relays publish almost nothing about a model: the OpenAI-compatible
|
|
5
|
+
* `/models` listing may carry only an id, the built-in catalog snapshot may
|
|
6
|
+
* predate the model, and the settings document may omit `reasoningEfforts`.
|
|
7
|
+
* The page then shows "reasoning unknown / context unknown" for a model the
|
|
8
|
+
* user knows perfectly well.
|
|
9
|
+
*
|
|
10
|
+
* This module owns the one evidence layer the user controls directly: a small
|
|
11
|
+
* JSON file outside the profile patch (`$DSH_HOME/dsh-model-visibility/
|
|
12
|
+
* overrides.json`) holding per-model facts that outrank every automatic source.
|
|
13
|
+
* It is deliberately not a Config field: the values are structured objects,
|
|
14
|
+
* they change on their own cadence, and a hand-edited file must keep working
|
|
15
|
+
* even while the settings document is being rewritten.
|
|
16
|
+
*
|
|
17
|
+
* The Host reads the file synchronously (the request-side image gate needs a
|
|
18
|
+
* synchronous answer) and re-reads it when the mtime changes, so editing the
|
|
19
|
+
* JSON by hand is enough to apply a fix.
|
|
20
|
+
*
|
|
21
|
+
* @module lib/model-overrides
|
|
22
|
+
*/
|
|
23
|
+
import fs from 'node:fs'
|
|
24
|
+
import os from 'node:os'
|
|
25
|
+
import path from 'node:path'
|
|
26
|
+
|
|
27
|
+
import { MODEL_KINDS } from './capability-catalog.js'
|
|
28
|
+
|
|
29
|
+
/** Input modalities the page can declare for a model. */
|
|
30
|
+
export const OVERRIDE_MODALITIES = ['text', 'image', 'audio', 'video', 'file']
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Effort ids offered by the editor. The list is intentionally wider than the
|
|
34
|
+
* `off/low/medium/high` ladder most adapters publish: relays and vendors also
|
|
35
|
+
* use `minimal`, `max` and `xhigh`, and the whole point of a manual override is
|
|
36
|
+
* to describe the upstream the adapter cannot describe.
|
|
37
|
+
*/
|
|
38
|
+
export const OVERRIDE_EFFORT_IDS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
|
|
39
|
+
|
|
40
|
+
/** Neutral display names for the standard ids; custom ids fall back to the id. */
|
|
41
|
+
const EFFORT_NAMES = {
|
|
42
|
+
off: 'Off',
|
|
43
|
+
minimal: 'Minimal',
|
|
44
|
+
low: 'Low',
|
|
45
|
+
medium: 'Medium',
|
|
46
|
+
high: 'High',
|
|
47
|
+
xhigh: 'XHigh',
|
|
48
|
+
max: 'Max',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Upper bound on a manual ladder; protects the composer from a huge array. */
|
|
52
|
+
const MAX_EFFORTS = 16
|
|
53
|
+
|
|
54
|
+
/** Read a trimmed non-empty string, or undefined. */
|
|
55
|
+
function text(value) {
|
|
56
|
+
if (typeof value !== 'string') return undefined
|
|
57
|
+
const trimmed = value.trim()
|
|
58
|
+
return trimmed === '' ? undefined : trimmed
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Unique known modalities, or undefined when the caller declared none. */
|
|
62
|
+
export function sanitizeModalities(value) {
|
|
63
|
+
if (!Array.isArray(value)) return undefined
|
|
64
|
+
const out = []
|
|
65
|
+
for (const item of value) {
|
|
66
|
+
if (typeof item !== 'string' || !OVERRIDE_MODALITIES.includes(item) || out.includes(item)) continue
|
|
67
|
+
out.push(item)
|
|
68
|
+
}
|
|
69
|
+
return out.length > 0 ? out : undefined
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Unique effort ids in the order the caller sent them. */
|
|
73
|
+
export function sanitizeEfforts(value) {
|
|
74
|
+
if (!Array.isArray(value)) return undefined
|
|
75
|
+
const ids = []
|
|
76
|
+
for (const item of value) {
|
|
77
|
+
const raw = item !== null && typeof item === 'object' ? item.id : item
|
|
78
|
+
const id = text(raw)?.toLowerCase()
|
|
79
|
+
if (id === undefined || !/^[a-z0-9][a-z0-9._-]{0,31}$/.test(id) || ids.includes(id)) continue
|
|
80
|
+
ids.push(id)
|
|
81
|
+
if (ids.length >= MAX_EFFORTS) break
|
|
82
|
+
}
|
|
83
|
+
if (ids.length === 0) return undefined
|
|
84
|
+
return ids.map((id) => ({ id, name: EFFORT_NAMES[id] ?? id }))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Normalize one override entry.
|
|
89
|
+
*
|
|
90
|
+
* An entry with no usable field returns undefined, which the store treats as
|
|
91
|
+
* "remove this override": the editor can therefore clear everything by sending
|
|
92
|
+
* empty checkboxes instead of a separate delete call.
|
|
93
|
+
* @param input - wire value from the editor or the JSON file.
|
|
94
|
+
* @returns a clean entry, or undefined when nothing was declared.
|
|
95
|
+
*/
|
|
96
|
+
export function sanitizeOverride(input) {
|
|
97
|
+
if (input === null || typeof input !== 'object') return undefined
|
|
98
|
+
const provider = text(input.provider)
|
|
99
|
+
const model = text(input.model)
|
|
100
|
+
if (provider === undefined || model === undefined) return undefined
|
|
101
|
+
const entry = { provider, model }
|
|
102
|
+
|
|
103
|
+
if (typeof input.kind === 'string' && MODEL_KINDS.includes(input.kind)) entry.kind = input.kind
|
|
104
|
+
|
|
105
|
+
const contextWindow = Number(input.contextWindow)
|
|
106
|
+
if (Number.isSafeInteger(contextWindow) && contextWindow > 0) entry.contextWindow = contextWindow
|
|
107
|
+
|
|
108
|
+
const modalities = sanitizeModalities(input.input)
|
|
109
|
+
if (modalities !== undefined) entry.input = modalities
|
|
110
|
+
|
|
111
|
+
const efforts = sanitizeEfforts(input.efforts)
|
|
112
|
+
if (efforts !== undefined) {
|
|
113
|
+
entry.efforts = efforts
|
|
114
|
+
const defaultEffort = text(input.defaultEffort)?.toLowerCase()
|
|
115
|
+
if (defaultEffort !== undefined && efforts.some((effort) => effort.id === defaultEffort)) {
|
|
116
|
+
entry.defaultEffort = defaultEffort
|
|
117
|
+
}
|
|
118
|
+
} else if (input.reasoningSupported === false) {
|
|
119
|
+
entry.reasoningSupported = false
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const declared =
|
|
123
|
+
entry.kind !== undefined ||
|
|
124
|
+
entry.contextWindow !== undefined ||
|
|
125
|
+
entry.input !== undefined ||
|
|
126
|
+
entry.efforts !== undefined ||
|
|
127
|
+
entry.reasoningSupported !== undefined
|
|
128
|
+
if (!declared) return undefined
|
|
129
|
+
entry.updatedAt = Date.now()
|
|
130
|
+
return entry
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Absolute path of the override file below `$DSH_HOME`. */
|
|
134
|
+
export function overrideFile(home) {
|
|
135
|
+
const base = home || process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
|
136
|
+
return path.join(base, 'dsh-model-visibility', 'overrides.json')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Identity key; provider ids never contain a NUL, model ids may contain "/". */
|
|
140
|
+
function keyOf(provider, model) {
|
|
141
|
+
return provider + '\u0000' + model
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* A small read-through store around the override file.
|
|
146
|
+
*
|
|
147
|
+
* Reads are memoized by mtime, so the hot path (one synchronous call per model
|
|
148
|
+
* on the request side) stays cheap while a hand edit still takes effect on the
|
|
149
|
+
* next read.
|
|
150
|
+
* @param options - `{ file?, home?, logger? }`.
|
|
151
|
+
* @returns `{ file, list, get, upsert, remove, invalidate }`.
|
|
152
|
+
*/
|
|
153
|
+
export function createOverrideStore(options = {}) {
|
|
154
|
+
const file = options.file || overrideFile(options.home)
|
|
155
|
+
const logger = options.logger ?? console
|
|
156
|
+
let entries = []
|
|
157
|
+
let loaded = false
|
|
158
|
+
let loadedMtime = -Infinity
|
|
159
|
+
|
|
160
|
+
const warn = (message, error) => {
|
|
161
|
+
if (typeof logger.warn === 'function') {
|
|
162
|
+
logger.warn(message)
|
|
163
|
+
if (error !== undefined) logger.warn(error)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** mtime of the file, or -Infinity when it does not exist. */
|
|
168
|
+
const mtimeOf = () => {
|
|
169
|
+
try {
|
|
170
|
+
return fs.statSync(file).mtimeMs
|
|
171
|
+
} catch (_error) {
|
|
172
|
+
return -Infinity
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const load = (force = false) => {
|
|
177
|
+
const mtime = mtimeOf()
|
|
178
|
+
if (!force && loaded && mtime === loadedMtime) return
|
|
179
|
+
loaded = true
|
|
180
|
+
loadedMtime = mtime
|
|
181
|
+
if (mtime === -Infinity) {
|
|
182
|
+
entries = []
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
187
|
+
const source = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.overrides) ? parsed.overrides : []
|
|
188
|
+
const next = []
|
|
189
|
+
for (const item of source) {
|
|
190
|
+
const entry = sanitizeOverride(item)
|
|
191
|
+
if (entry !== undefined) next.push(entry)
|
|
192
|
+
}
|
|
193
|
+
entries = next
|
|
194
|
+
} catch (error) {
|
|
195
|
+
warn('model-visibility: could not read model overrides; ignoring the file')
|
|
196
|
+
warn(error)
|
|
197
|
+
entries = []
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const write = () => {
|
|
202
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
203
|
+
const temp = `${file}.${process.pid}.tmp`
|
|
204
|
+
fs.writeFileSync(temp, `${JSON.stringify({ version: 1, overrides: entries }, null, 2)}\n`)
|
|
205
|
+
fs.renameSync(temp, file)
|
|
206
|
+
loadedMtime = mtimeOf()
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Public entries as detached copies, in file order. */
|
|
210
|
+
const list = () => {
|
|
211
|
+
load()
|
|
212
|
+
return entries.map((entry) => ({ ...entry }))
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** One entry for a provider/model pair, or undefined. */
|
|
216
|
+
const get = (provider, model) => {
|
|
217
|
+
if (typeof provider !== 'string' || typeof model !== 'string') return undefined
|
|
218
|
+
load()
|
|
219
|
+
const key = keyOf(provider, model)
|
|
220
|
+
const found = entries.find((entry) => keyOf(entry.provider, entry.model) === key)
|
|
221
|
+
return found === undefined ? undefined : { ...found }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Insert or replace one entry; an empty declaration removes it. */
|
|
225
|
+
const upsert = (raw) => {
|
|
226
|
+
const entry = sanitizeOverride(raw)
|
|
227
|
+
load(true)
|
|
228
|
+
if (entry === undefined) {
|
|
229
|
+
if (raw !== null && typeof raw === 'object') {
|
|
230
|
+
const provider = text(raw.provider)
|
|
231
|
+
const model = text(raw.model)
|
|
232
|
+
if (provider !== undefined && model !== undefined) return remove(provider, model)
|
|
233
|
+
}
|
|
234
|
+
return undefined
|
|
235
|
+
}
|
|
236
|
+
const key = keyOf(entry.provider, entry.model)
|
|
237
|
+
const index = entries.findIndex((item) => keyOf(item.provider, item.model) === key)
|
|
238
|
+
if (index === -1) entries.push(entry)
|
|
239
|
+
else entries[index] = entry
|
|
240
|
+
write()
|
|
241
|
+
return { ...entry }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Drop one entry; returns true when the file changed. */
|
|
245
|
+
const remove = (provider, model) => {
|
|
246
|
+
load(true)
|
|
247
|
+
const key = keyOf(provider, model)
|
|
248
|
+
const next = entries.filter((entry) => keyOf(entry.provider, entry.model) !== key)
|
|
249
|
+
if (next.length === entries.length) return false
|
|
250
|
+
entries = next
|
|
251
|
+
write()
|
|
252
|
+
return true
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Forget the memoized copy; the next read consults the file again. */
|
|
256
|
+
const invalidate = () => {
|
|
257
|
+
loaded = false
|
|
258
|
+
loadedMtime = -Infinity
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return { file, list, get, upsert, remove, invalidate }
|
|
262
|
+
}
|