@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,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
+ }