dsh-oc-tui 0.2.0 → 0.3.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/README.md +21 -563
- package/bin/mcp-sse-bridge.mjs +499 -499
- package/docs/assets/conversation.png +0 -0
- package/docs/assets/cover.png +0 -0
- package/docs/assets/home.png +0 -0
- package/docs/assets/settings.png +0 -0
- package/docs/dsh-std-/346/216/245/345/205/245/350/257/264/346/230/216.md +566 -0
- package/docs/user-guide.md +616 -0
- package/docs//347/224/250/346/210/267/346/211/213/345/206/214.md +607 -593
- package/dsh-plugin.json +69 -69
- package/lib/boot-block.js +129 -0
- package/lib/diagnostics.js +162 -120
- package/lib/dsh-version.js +161 -0
- package/lib/git-branch.js +157 -157
- package/lib/i18n.js +283 -283
- package/lib/image.js +2 -1
- package/lib/index.js +5183 -4812
- package/lib/locales/ar.js +670 -635
- package/lib/locales/de.js +639 -604
- package/lib/locales/en.js +35 -0
- package/lib/locales/es.js +639 -604
- package/lib/locales/fr.js +639 -604
- package/lib/locales/id.js +641 -606
- package/lib/locales/it.js +639 -604
- package/lib/locales/ja.js +639 -604
- package/lib/locales/ko.js +639 -604
- package/lib/locales/nl.js +639 -604
- package/lib/locales/pl.js +653 -618
- package/lib/locales/pt-BR.js +639 -604
- package/lib/locales/ru.js +653 -618
- package/lib/locales/tr.js +639 -604
- package/lib/locales/vi.js +641 -606
- package/lib/locales/zh-CN.js +640 -605
- package/lib/locales/zh-TW.js +640 -605
- package/lib/markdown.js +101 -27
- package/lib/mcp-monitor.js +189 -0
- package/lib/mcp-skill.js +93 -93
- package/lib/mcp.js +865 -838
- package/lib/model-route.js +66 -0
- package/lib/providers.js +712 -712
- package/lib/screenshot.js +140 -0
- package/lib/splash-opencode.js +29 -26
- package/lib/startup.js +66 -48
- package/lib/term.js +53 -11
- package/lib/themes.js +101 -0
- package/lib/ui.js +685 -443
- package/lib/updates.js +5 -2
- package/lib/web-settings.js +52 -21
- package/package.json +160 -146
- package/skills/mcp-import/SKILL.md +216 -216
package/lib/providers.js
CHANGED
|
@@ -1,712 +1,712 @@
|
|
|
1
|
-
// Adding a model provider from the TUI: the WebUI's add flow, expressed as the
|
|
2
|
-
// settings dialog's own sub-views. The WebUI offers two ways to gain a provider
|
|
3
|
-
// — adopt one the adapter already ships, or hand-declare a route it does not —
|
|
4
|
-
// and this file owns both: the directory split that decides which routes are
|
|
5
|
-
// still addable, the create draft, its validation, and the write.
|
|
6
|
-
//
|
|
7
|
-
// Nothing here caches: the Model tab re-reads the settings descriptors and the
|
|
8
|
-
// llm directory on every render, so a provider added by another surface (the
|
|
9
|
-
// WebUI, a hand-edited patch) appears as soon as it is written.
|
|
10
|
-
//
|
|
11
|
-
// The host services are reached through `ctx.get()` and never imported, so the
|
|
12
|
-
// plugin still installs and boots on a host that ships none of them.
|
|
13
|
-
|
|
14
|
-
import z from '@deepseek-ai/schemastery'
|
|
15
|
-
import { t, tv } from './i18n.js'
|
|
16
|
-
import { formatError } from './util.js'
|
|
17
|
-
|
|
18
|
-
// Any route key walks a dict schema to the same profile node, so this one —
|
|
19
|
-
// which cannot occur in a provider id — addresses one profile's fields without
|
|
20
|
-
// resolving a real route.
|
|
21
|
-
const PROBE_ROUTE = '\u0000probe'
|
|
22
|
-
|
|
23
|
-
// A route id usable as a settings key AND as the stem of a credential name. The
|
|
24
|
-
// leading letter is the second half of that: `routeKeyRef` uppercases the id and
|
|
25
|
-
// replaces every non-alphanumeric run with `_`, and a credential reference is a
|
|
26
|
-
// POSIX shell identifier, which cannot start with a digit.
|
|
27
|
-
const ROUTE_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
|
|
28
|
-
|
|
29
|
-
// Families whose profile carries a per-route endpoint. A whole-section provider
|
|
30
|
-
// (DeepSeek, the account route) resolves its endpoint from its own Config, so it
|
|
31
|
-
// has no URL row of its own.
|
|
32
|
-
export const URL_FAMILIES = new Set(['llm-deepseek', 'llm-pi-ai'])
|
|
33
|
-
|
|
34
|
-
// The route the account family owns: its credential is a sign-in, not a stored
|
|
35
|
-
// key, so it gets no key row and no removal.
|
|
36
|
-
export const ACCOUNT_ROUTE = 'deepseek-account'
|
|
37
|
-
|
|
38
|
-
// The credential reference a route's key is stored under — the same
|
|
39
|
-
// `<ROUTE>_API_KEY` derivation the WebUI makes, so a key stored from either
|
|
40
|
-
// surface is found by both.
|
|
41
|
-
export function routeKeyRef(route) {
|
|
42
|
-
const id = String(route ?? '')
|
|
43
|
-
if (id === '') return ''
|
|
44
|
-
return id.toUpperCase().replace(/[^A-Z0-9]+/g, '_') + '_API_KEY'
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// A provider's stored model catalog tolerates both string entries and
|
|
48
|
-
// `{ id, ... }` objects; normalize to objects with an id so the merged view and
|
|
49
|
-
// the picker can treat them uniformly.
|
|
50
|
-
export function normalizeModels(models) {
|
|
51
|
-
if (!Array.isArray(models)) return []
|
|
52
|
-
const out = []
|
|
53
|
-
for (const entry of models) {
|
|
54
|
-
if (typeof entry === 'string' && entry) out.push({ id: entry })
|
|
55
|
-
else if (entry && typeof entry === 'object' && typeof entry.id === 'string' && entry.id) out.push(entry)
|
|
56
|
-
}
|
|
57
|
-
return out
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function modelEntryId(entry) {
|
|
61
|
-
return typeof entry === 'string' ? entry : entry?.id
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function valueAt(source, path) {
|
|
65
|
-
let value = source
|
|
66
|
-
for (const part of path) {
|
|
67
|
-
if (typeof value !== 'object' || value === null) return undefined
|
|
68
|
-
value = value[part]
|
|
69
|
-
}
|
|
70
|
-
return value
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export function hasPath(source, path) {
|
|
74
|
-
let value = source
|
|
75
|
-
for (const part of path) {
|
|
76
|
-
if (typeof value !== 'object' || value === null || !Object.hasOwn(value, part)) return false
|
|
77
|
-
value = value[part]
|
|
78
|
-
}
|
|
79
|
-
return true
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Picker/choice annotation: a glanceable capability marker per model.
|
|
83
|
-
export function capabilitySuffix(model) {
|
|
84
|
-
const capabilities = []
|
|
85
|
-
if (Array.isArray(model.inputModalities) && model.inputModalities.includes('image')) capabilities.push('vision')
|
|
86
|
-
if (model.reasoning && Array.isArray(model.reasoning.efforts) && model.reasoning.efforts.length > 0) capabilities.push('thinking')
|
|
87
|
-
return capabilities.length > 0 ? ' (' + capabilities.join(', ') + ')' : ''
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// Discover the models a provider profile advertises. Uses the model-discovery
|
|
91
|
-
// seam (`ctx.llm.discoverModels`): it answers from the installed catalog for a
|
|
92
|
-
// known route and interrogates a custom/dormant route's endpoint — resolving the
|
|
93
|
-
// stored credential itself, or using `record.apiKey` for a key the create form
|
|
94
|
-
// holds but has not stored yet. `listModels` only covers registered adapters, so
|
|
95
|
-
// it silently missed user-added providers that are not currently registered; it
|
|
96
|
-
// is kept as the fallback for namespaces that register no discovery (e.g. the
|
|
97
|
-
// built-in DeepSeek adapter).
|
|
98
|
-
//
|
|
99
|
-
// The refusal text is returned, not swallowed: "the endpoint answered 401" and
|
|
100
|
-
// "this protocol has no model listing" are the two answers a user can act on,
|
|
101
|
-
// and an empty list with no reason is what makes a fetch look broken.
|
|
102
|
-
export async function fetchProviderModels(ctx, entry, record) {
|
|
103
|
-
const llm = ctx.get('llm')
|
|
104
|
-
const source = typeof record === 'object' && record !== null ? record : {}
|
|
105
|
-
const text = (value) => (typeof value === 'string' ? value.trim() : '')
|
|
106
|
-
let fetched = null
|
|
107
|
-
let failure
|
|
108
|
-
if (llm?.discoverModels) {
|
|
109
|
-
const request = {
|
|
110
|
-
// An empty route id is not a route: omitting it lets the adapter treat the
|
|
111
|
-
// draft as endpoint-only instead of looking up "" in its catalog.
|
|
112
|
-
...(text(entry.provider) === '' ? {} : { provider: entry.provider }),
|
|
113
|
-
...(text(source.baseURL) === '' ? {} : { baseURL: source.baseURL }),
|
|
114
|
-
...(text(source.api) === '' ? {} : { api: source.api }),
|
|
115
|
-
// A key typed into the create form and not stored yet: asking with it is
|
|
116
|
-
// what makes adding a provider one pass instead of save-then-return.
|
|
117
|
-
...(text(source.apiKey) === '' ? {} : { apiKey: source.apiKey }),
|
|
118
|
-
}
|
|
119
|
-
try {
|
|
120
|
-
fetched = await llm.discoverModels(entry.settingsNs, request)
|
|
121
|
-
} catch (error) {
|
|
122
|
-
// `NO_DISCOVERY` means the namespace registered no discovery; its
|
|
123
|
-
// registered adapter answers from its own catalog below.
|
|
124
|
-
if (error?.code !== 'NO_DISCOVERY') failure = formatError(error)
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
if (!Array.isArray(fetched) && failure === undefined && llm?.listModels) {
|
|
128
|
-
try { fetched = await llm.listModels(entry.provider) } catch (error) { failure = formatError(error) }
|
|
129
|
-
}
|
|
130
|
-
// A callback that answered with something other than a list is a refusal too,
|
|
131
|
-
// even though nothing was thrown.
|
|
132
|
-
if (!Array.isArray(fetched) && failure === undefined) failure = t('model.fetchRefused')
|
|
133
|
-
return { models: normalizeModels(fetched), failed: failure !== undefined, error: failure }
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// Everything the Model tab and the add flows read, in one place: the settings
|
|
137
|
-
// service's redacted namespace views, the llm directory, and the two halves the
|
|
138
|
-
// Model tab splits that directory into — the routes whose profile resolves (a
|
|
139
|
-
// block of their own) and the ones it does not (the add picker's rows).
|
|
140
|
-
//
|
|
141
|
-
// A whole-section route (`settingsPath: []`) is always configured: its profile
|
|
142
|
-
// is the plugin entry's own Config, which exists whether or not the user has
|
|
143
|
-
// touched it. Getting that wrong is what hid DeepSeek from the Model tab on a
|
|
144
|
-
// fresh install, since the user layer of an untouched entry is empty.
|
|
145
|
-
//
|
|
146
|
-
// Returns undefined when the host exposes no settings service at all, which the
|
|
147
|
-
// caller renders as an explanatory row instead of an empty page.
|
|
148
|
-
export function providerDirectory(ctx) {
|
|
149
|
-
const settings = ctx.get('settings')
|
|
150
|
-
if (!settings) return undefined
|
|
151
|
-
let described
|
|
152
|
-
try { described = settings.describe({ redactSecrets: true }) } catch { return undefined }
|
|
153
|
-
const descriptors = new Map(described.map((entry) => [String(entry.ns), entry]))
|
|
154
|
-
const llm = ctx.get('llm')
|
|
155
|
-
const directory = llm?.listConfigurableProviders?.() ?? []
|
|
156
|
-
const configured = []
|
|
157
|
-
const addable = []
|
|
158
|
-
for (const entry of directory) {
|
|
159
|
-
const descriptor = descriptors.get(entry.settingsNs)
|
|
160
|
-
if (!descriptor) continue
|
|
161
|
-
const path = Array.isArray(entry.settingsPath) ? entry.settingsPath : []
|
|
162
|
-
const profile = path.length === 0 ? descriptor.value : valueAt(descriptor.value, path)
|
|
163
|
-
const row = { entry, descriptor, path, profile }
|
|
164
|
-
if (profile !== undefined) configured.push(row)
|
|
165
|
-
else addable.push(row)
|
|
166
|
-
}
|
|
167
|
-
return { settings, descriptors, llm, configured, addable }
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// Walk a rehydrated schema to the node a path addresses. Dict and array nodes
|
|
171
|
-
// have no `dict` of their own — every key walks to their shared `inner` node.
|
|
172
|
-
function nodeAtPath(root, path) {
|
|
173
|
-
let node = root
|
|
174
|
-
for (const key of path) {
|
|
175
|
-
if (node === undefined) return undefined
|
|
176
|
-
if (node.type === 'object') node = node.dict?.[key]
|
|
177
|
-
else if (node.type === 'dict' || node.type === 'array') node = node.inner
|
|
178
|
-
else return undefined
|
|
179
|
-
}
|
|
180
|
-
return node
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// The wire protocols a profile may name, read out of the owning namespace's own
|
|
184
|
-
// schema so the choices offered cannot drift from the ones the adapter accepts:
|
|
185
|
-
// both come from the same Config. Hardcoding them is how the TUI offered
|
|
186
|
-
// `google-generative-ai`, which this adapter family cannot serve at all.
|
|
187
|
-
export function protocolChoices(descriptor) {
|
|
188
|
-
const serialized = descriptor?.schema
|
|
189
|
-
if (!serialized) return []
|
|
190
|
-
let node
|
|
191
|
-
try {
|
|
192
|
-
node = nodeAtPath(new z(serialized), ['providers', PROBE_ROUTE, 'api'])
|
|
193
|
-
} catch { return [] }
|
|
194
|
-
if (node?.type !== 'union' || !Array.isArray(node.list)) return []
|
|
195
|
-
return node.list.map((entry) => entry.value).filter((value) => typeof value === 'string')
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// The namespace a hand-declared route is written into, with the revision and
|
|
199
|
-
// the protocol choices its own schema declares.
|
|
200
|
-
//
|
|
201
|
-
// Found by probe rather than by name: only a namespace whose schema describes
|
|
202
|
-
// `providers.<route>.api` can hold a declared route, so the first one that
|
|
203
|
-
// answers IS that namespace — however a profile happens to name its entry.
|
|
204
|
-
export function declareNamespace(directory) {
|
|
205
|
-
for (const [ns, descriptor] of directory.descriptors) {
|
|
206
|
-
const protocols = protocolChoices(descriptor)
|
|
207
|
-
if (protocols.length > 0) return { settingsNs: ns, revision: descriptor.revision, protocols }
|
|
208
|
-
}
|
|
209
|
-
return undefined
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// The add picker's rows. A catalog route the adapter ships has a single-letter
|
|
213
|
-
// name that means nothing to a user scanning 40 of them, so the top row is a
|
|
214
|
-
// filter: the list is what matches it.
|
|
215
|
-
//
|
|
216
|
-
// The custom entry sits above the catalog rather than after it — declaring an
|
|
217
|
-
// endpoint is what a user with an unfamilar gateway is looking for, and being
|
|
218
|
-
// 40 rows down is the same as absent.
|
|
219
|
-
export function providerPickerItems(directory, { query = '' } = {}) {
|
|
220
|
-
const items = [{
|
|
221
|
-
kind: 'provider-search',
|
|
222
|
-
label: t('provider.search'),
|
|
223
|
-
value: query === '' ? t('provider.searchPlaceholder') : query,
|
|
224
|
-
disabled: false,
|
|
225
|
-
}]
|
|
226
|
-
items.push({ kind: 'provider-pick-custom', label: t('provider.custom'), value: 'Enter', disabled: false })
|
|
227
|
-
items.push({ kind: 'header', label: t('provider.catalog'), value: '', disabled: true })
|
|
228
|
-
const rows = filterAddable(directory.addable, query)
|
|
229
|
-
if (rows.length === 0) {
|
|
230
|
-
items.push({
|
|
231
|
-
kind: 'provider-empty',
|
|
232
|
-
// "everything is configured" and "nothing matches what you typed" are
|
|
233
|
-
// different answers, and the second one invites a different next keystroke.
|
|
234
|
-
label: directory.addable.length === 0
|
|
235
|
-
? t('provider.catalogEmpty')
|
|
236
|
-
: t('provider.searchEmpty', { query: String(query).trim() }),
|
|
237
|
-
wide: true,
|
|
238
|
-
disabled: true,
|
|
239
|
-
selectable: false,
|
|
240
|
-
})
|
|
241
|
-
}
|
|
242
|
-
for (const row of rows) {
|
|
243
|
-
items.push({
|
|
244
|
-
kind: 'provider-pick',
|
|
245
|
-
label: row.entry.displayName,
|
|
246
|
-
// A hand-declared route's name is the user's own text; the route id is
|
|
247
|
-
// what identifies it in the settings and is worth showing beside it.
|
|
248
|
-
value: row.entry.declared === true ? row.entry.provider : '',
|
|
249
|
-
providerId: row.entry.provider,
|
|
250
|
-
settingsNs: row.entry.settingsNs,
|
|
251
|
-
revision: row.descriptor.revision,
|
|
252
|
-
disabled: false,
|
|
253
|
-
})
|
|
254
|
-
}
|
|
255
|
-
return items
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// The addable routes matching a picker query: the route id (what a user who
|
|
259
|
-
// knows the provider types) and its display name (what a user who does not reads).
|
|
260
|
-
export function filterAddable(addable, query) {
|
|
261
|
-
const needle = String(query ?? '').trim().toLowerCase()
|
|
262
|
-
if (needle === '') return addable
|
|
263
|
-
return addable.filter((row) => row.entry.provider.toLowerCase().includes(needle)
|
|
264
|
-
|| String(row.entry.displayName ?? '').toLowerCase().includes(needle))
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// A create draft starts from the picker's choice: adopting a route it already
|
|
268
|
-
// names, or declaring one from nothing.
|
|
269
|
-
export function createDraft(mode = 'catalog', route = '') {
|
|
270
|
-
return { mode, route, displayName: '', baseURL: '', api: '', key: '', models: [] }
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// A route id derived from a display name.
|
|
274
|
-
//
|
|
275
|
-
// The route is a settings key and a credential stem, so it must be spelled a
|
|
276
|
-
// particular way — but the user is thinking in the name they see in their
|
|
277
|
-
// gateway's console. Typing the name first is the common order, so the form
|
|
278
|
-
// fills the key from it; only characters a route may carry survive, and when
|
|
279
|
-
// nothing usable is left this returns '' so validation still says why.
|
|
280
|
-
export function deriveRoute(displayName) {
|
|
281
|
-
const slug = String(displayName ?? '')
|
|
282
|
-
.toLowerCase()
|
|
283
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
284
|
-
.replace(/^-+|-+$/g, '')
|
|
285
|
-
// A credential reference cannot start with a digit, and neither may a route.
|
|
286
|
-
.replace(/^\d+/, '')
|
|
287
|
-
return ROUTE_PATTERN.test(slug) ? slug : ''
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
// The create form's rows. One list for both modes so the shared fields (key,
|
|
291
|
-
// models) are built once; the mode decides the identity rows.
|
|
292
|
-
//
|
|
293
|
-
// `protocols` arrives resolved: the caller read it out of the schema, because a
|
|
294
|
-
// draft has no stored profile for this file to inspect. `title` names the route
|
|
295
|
-
// a catalog draft is adopting — the picker already chose it, so the form shows
|
|
296
|
-
// which one instead of asking again.
|
|
297
|
-
export function buildCreateFormItems(draft, { protocols = [], title } = {}) {
|
|
298
|
-
const custom = draft.mode === 'custom'
|
|
299
|
-
const items = [{
|
|
300
|
-
kind: 'header',
|
|
301
|
-
label: title ?? t(custom ? 'provider.form.custom' : 'provider.form.catalog'),
|
|
302
|
-
value: '',
|
|
303
|
-
disabled: true,
|
|
304
|
-
}]
|
|
305
|
-
if (custom) {
|
|
306
|
-
items.push({
|
|
307
|
-
kind: 'provider-field',
|
|
308
|
-
field: 'route',
|
|
309
|
-
label: t('provider.field.route'),
|
|
310
|
-
value: draft.route,
|
|
311
|
-
providerText: true,
|
|
312
|
-
})
|
|
313
|
-
items.push({ kind: 'provider-hint', label: routeHint(draft.route), wide: true, disabled: true, selectable: false })
|
|
314
|
-
items.push({ kind: 'provider-field', field: 'displayName', label: t('model.displayName'), value: draft.displayName, providerText: true })
|
|
315
|
-
}
|
|
316
|
-
items.push({ kind: 'provider-field', field: 'baseURL', label: t('model.providerUrl'), value: draft.baseURL, providerText: true })
|
|
317
|
-
if (custom) {
|
|
318
|
-
items.push({
|
|
319
|
-
kind: 'provider-field',
|
|
320
|
-
field: 'api',
|
|
321
|
-
label: t('model.wireProtocol'),
|
|
322
|
-
value: draft.api || protocols[0] || '',
|
|
323
|
-
options: protocols.map((id) => ({ label: protocolLabel(id), value: id })),
|
|
324
|
-
})
|
|
325
|
-
}
|
|
326
|
-
items.push({
|
|
327
|
-
kind: 'provider-field',
|
|
328
|
-
field: 'key',
|
|
329
|
-
label: t('model.providerApiKey'),
|
|
330
|
-
// The draft's key is never echoed: the row shows that one was typed, and
|
|
331
|
-
// the editor starts blank so Enter replaces rather than appends to it.
|
|
332
|
-
value: draft.key ? '••••••' : '',
|
|
333
|
-
secret: true,
|
|
334
|
-
})
|
|
335
|
-
const stored = draft.models.length
|
|
336
|
-
items.push({
|
|
337
|
-
kind: 'provider-field',
|
|
338
|
-
field: 'models',
|
|
339
|
-
label: t('model.models'),
|
|
340
|
-
value: stored > 0 ? t('model.selectedCount', { count: stored }) : t('model.fetch'),
|
|
341
|
-
providerModels: true,
|
|
342
|
-
})
|
|
343
|
-
for (const model of draft.models) {
|
|
344
|
-
items.push({
|
|
345
|
-
kind: 'provider-form-model',
|
|
346
|
-
label: model.id,
|
|
347
|
-
value: capabilitySuffix(model),
|
|
348
|
-
indent: 1,
|
|
349
|
-
disabled: true,
|
|
350
|
-
selectable: false,
|
|
351
|
-
})
|
|
352
|
-
}
|
|
353
|
-
items.push({ kind: 'provider-field', field: 'addModel', label: t('provider.field.addModel'), value: '', providerText: true })
|
|
354
|
-
items.push({ kind: 'provider-save', label: t('provider.field.save'), value: 'Enter', disabled: false })
|
|
355
|
-
return items
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
// The route row's own hint: what an id may look like, and where its key will be
|
|
359
|
-
// stored. A rejected id is reported on save, not while typing — the TUI edits
|
|
360
|
-
// one row at a time and has no place for a live inline error.
|
|
361
|
-
function routeHint(route) {
|
|
362
|
-
if (route === '') return t('provider.hint.route', { ref: routeKeyRef('acme-gateway') })
|
|
363
|
-
if (!ROUTE_PATTERN.test(route)) return t('provider.error.route')
|
|
364
|
-
return t('provider.hint.route', { ref: routeKeyRef(route) })
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
// Protocol ids are technical tokens, but two of them read as words; the rest
|
|
368
|
-
// stay verbatim (see `tv`). Kept local so a new protocol needs no catalog work.
|
|
369
|
-
function protocolLabel(id) {
|
|
370
|
-
const label = tv(id)
|
|
371
|
-
return label === id ? id : label
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
function isHttpUrl(value) {
|
|
375
|
-
try {
|
|
376
|
-
const protocol = new URL(String(value ?? '').trim()).protocol
|
|
377
|
-
return protocol === 'http:' || protocol === 'https:'
|
|
378
|
-
} catch {
|
|
379
|
-
return false
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// Whether a draft can be written, or why it cannot. The message names the field
|
|
384
|
-
// the user is looking at rather than repeating the schema's own path-shaped
|
|
385
|
-
// refusal, which arrives only after the write has already begun.
|
|
386
|
-
//
|
|
387
|
-
// A catalog route needs nothing but a route: its profile inherits the adapter's
|
|
388
|
-
// own endpoint, protocol, and model catalog, so an empty profile is a valid way
|
|
389
|
-
// to add one. A hand-declared route has nothing to inherit — the three fields it
|
|
390
|
-
// cannot default (endpoint, protocol, at least one model) are required here.
|
|
391
|
-
export function validateCreateDraft(draft, { taken = [] } = {}) {
|
|
392
|
-
const route = String(draft.route ?? '').trim()
|
|
393
|
-
if (draft.mode === 'custom') {
|
|
394
|
-
if (!ROUTE_PATTERN.test(route)) return t('provider.error.route')
|
|
395
|
-
if (taken.includes(route)) return t('provider.error.taken')
|
|
396
|
-
const endpoint = String(draft.baseURL ?? '').trim()
|
|
397
|
-
if (endpoint === '') return t('provider.error.baseUrlNeeded')
|
|
398
|
-
if (!isHttpUrl(endpoint)) return t('provider.error.baseUrl')
|
|
399
|
-
if (!String(draft.api ?? '').trim()) return t('provider.error.apiNeeded')
|
|
400
|
-
if (draft.models.length === 0) return t('provider.error.modelsNeeded')
|
|
401
|
-
} else {
|
|
402
|
-
if (route === '') return t('provider.error.routeNeeded')
|
|
403
|
-
const endpoint = String(draft.baseURL ?? '').trim()
|
|
404
|
-
if (endpoint !== '' && !isHttpUrl(endpoint)) return t('provider.error.baseUrl')
|
|
405
|
-
}
|
|
406
|
-
const seen = new Set()
|
|
407
|
-
for (let index = 0; index < draft.models.length; index++) {
|
|
408
|
-
const id = String(modelEntryId(draft.models[index]) ?? '').trim()
|
|
409
|
-
if (id === '') return t('provider.error.modelId', { index: index + 1 })
|
|
410
|
-
if (seen.has(id)) return t('provider.error.modelDuplicate', { id })
|
|
411
|
-
seen.add(id)
|
|
412
|
-
}
|
|
413
|
-
return undefined
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// Write a create draft: the whole profile at `providers.<route>` in one
|
|
417
|
-
// `settings.mutate`, then the key through the credentials service.
|
|
418
|
-
//
|
|
419
|
-
// The order is the WebUI's and it matters: a profile naming an `apiKeyEnv` it
|
|
420
|
-
// never stores is a route that cannot authenticate, while a stored key with no
|
|
421
|
-
// profile is an orphan the removal path would not clean up. The profile goes
|
|
422
|
-
// first, so a failed key write leaves a provider the user can retry without
|
|
423
|
-
// creating it twice.
|
|
424
|
-
//
|
|
425
|
-
// `revision` is the namespace revision the form opened at, so a route declared
|
|
426
|
-
// by another surface meanwhile is a conflict instead of an overwrite.
|
|
427
|
-
export async function saveCreateDraft(ctx, settings, draft, { settingsNs, revision } = {}) {
|
|
428
|
-
const route = String(draft.route ?? '').trim()
|
|
429
|
-
const displayName = String(draft.displayName ?? '').trim()
|
|
430
|
-
const endpoint = String(draft.baseURL ?? '').trim()
|
|
431
|
-
const api = String(draft.api ?? '').trim()
|
|
432
|
-
const key = String(draft.key ?? '').trim()
|
|
433
|
-
const profile = {}
|
|
434
|
-
// The profile names the conventional reference only when a key is about to be
|
|
435
|
-
// stored: a route added with the key left blank keeps its provider-native
|
|
436
|
-
// authentication (a credential chain, ADC) instead of resolving a reference
|
|
437
|
-
// nothing ever sets.
|
|
438
|
-
if (key !== '') profile.apiKeyEnv = routeKeyRef(route)
|
|
439
|
-
if (displayName !== '') profile.displayName = displayName
|
|
440
|
-
if (api !== '') profile.api = api
|
|
441
|
-
if (endpoint !== '') profile.baseURL = endpoint
|
|
442
|
-
if (draft.models.length > 0) profile.models = draft.models.map((model) => ({ ...model }))
|
|
443
|
-
await settings.mutate(settingsNs, [{ op: 'set', path: ['providers', route], value: profile }], revision)
|
|
444
|
-
if (key !== '') {
|
|
445
|
-
const credentials = ctx.get('credentials')
|
|
446
|
-
if (!credentials) throw new Error(t('model.credentialsUnavailable'))
|
|
447
|
-
await credentials.set(profile.apiKeyEnv, key)
|
|
448
|
-
}
|
|
449
|
-
return route
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
// The model-selection rows for a create draft. `discovered` is the endpoint's
|
|
453
|
-
// answer, cached by the caller: a toggle must not spend a round trip, and the
|
|
454
|
-
// list must not shift under the cursor because the endpoint answered twice.
|
|
455
|
-
//
|
|
456
|
-
// The first row is the whole-selection toggle: a fresh fetch arrives with every
|
|
457
|
-
// candidate already picked (that is what "get the models" was for), so the one
|
|
458
|
-
// keystroke a user reliably needs is the way back out of that.
|
|
459
|
-
export function draftModelItems(draft, discovered, { failed = false, error, providerId } = {}) {
|
|
460
|
-
const items = []
|
|
461
|
-
if (error) {
|
|
462
|
-
// The refusal is the only answer a user can act on: 401 means the key, a
|
|
463
|
-
// bare host means the endpoint, "no listing" means type the ids by hand.
|
|
464
|
-
items.push({ kind: 'model-error', label: error, wide: true, indent: 1, tone: 'warning', disabled: true, selectable: false })
|
|
465
|
-
}
|
|
466
|
-
const candidates = normalizeModels(discovered)
|
|
467
|
-
const storedIds = new Set(draft.models.map((model) => modelEntryId(model)))
|
|
468
|
-
const seen = new Set()
|
|
469
|
-
const rows = []
|
|
470
|
-
const pushRow = (model) => {
|
|
471
|
-
const id = modelEntryId(model)
|
|
472
|
-
if (!id || seen.has(id)) return
|
|
473
|
-
seen.add(id)
|
|
474
|
-
const checked = storedIds.has(id)
|
|
475
|
-
// The saved entry is what carries capacities; the discovered one supplies
|
|
476
|
-
// them for a model the draft has not adopted yet.
|
|
477
|
-
const saved = draft.models.find((entry) => modelEntryId(entry) === id)
|
|
478
|
-
rows.push({
|
|
479
|
-
kind: 'model-toggle',
|
|
480
|
-
label: id + capabilitySuffix(model),
|
|
481
|
-
value: (checked ? '[x]' : '[ ]') + capacitySuffix(saved ?? model),
|
|
482
|
-
modelId: id,
|
|
483
|
-
checked,
|
|
484
|
-
draftToggle: true,
|
|
485
|
-
candidate: model,
|
|
486
|
-
providerId,
|
|
487
|
-
disabled: false,
|
|
488
|
-
})
|
|
489
|
-
}
|
|
490
|
-
for (const model of candidates) pushRow(model)
|
|
491
|
-
// Models the catalog no longer returns, or that the user typed by hand, stay
|
|
492
|
-
// toggleable so a stored selection is never a row the window cannot show.
|
|
493
|
-
for (const model of draft.models) pushRow(model)
|
|
494
|
-
if (candidates.length > 0) {
|
|
495
|
-
const picked = candidates.filter((model) => storedIds.has(modelEntryId(model))).length
|
|
496
|
-
items.push({
|
|
497
|
-
kind: 'model-select-all',
|
|
498
|
-
label: t('model.selectAll'),
|
|
499
|
-
value: (picked === candidates.length ? '[x] ' : '[ ] ') + picked + '/' + candidates.length,
|
|
500
|
-
allPicked: picked === candidates.length,
|
|
501
|
-
draftToggle: true,
|
|
502
|
-
providerId,
|
|
503
|
-
disabled: false,
|
|
504
|
-
})
|
|
505
|
-
}
|
|
506
|
-
items.push(...rows)
|
|
507
|
-
if (rows.length === 0) {
|
|
508
|
-
items.push({ label: failed ? t('model.noModels') : t('model.noModelsReturned'), value: '', disabled: true })
|
|
509
|
-
}
|
|
510
|
-
return items
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
// Every advertised model, merged into a saved list: an entry that is already
|
|
514
|
-
// there keeps its shape (and its position, so nothing the user corrected is
|
|
515
|
-
// rewritten), and a candidate joins with whatever the endpoint reported.
|
|
516
|
-
export function mergeModelSelection(stored, candidates) {
|
|
517
|
-
const next = [...stored]
|
|
518
|
-
for (const candidate of normalizeModels(candidates)) {
|
|
519
|
-
if (next.some((entry) => modelEntryId(entry) === candidate.id)) continue
|
|
520
|
-
next.push(adoptDiscovered(candidate) ?? { id: candidate.id })
|
|
521
|
-
}
|
|
522
|
-
return next
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
// Adopt one discovered model into a draft, keeping the metadata the endpoint
|
|
526
|
-
// advertised: without a context window and token cap a hand-declared model
|
|
527
|
-
// falls back to the route defaults, which is usually wrong for the model.
|
|
528
|
-
export function adoptDiscovered(model) {
|
|
529
|
-
const candidate = normalizeModels([model])[0]
|
|
530
|
-
if (!candidate) return undefined
|
|
531
|
-
const out = { id: candidate.id }
|
|
532
|
-
if (typeof candidate.name === 'string' && candidate.name && candidate.name !== candidate.id) out.name = candidate.name
|
|
533
|
-
if (Number.isInteger(candidate.contextWindow) && candidate.contextWindow > 0) out.contextWindow = candidate.contextWindow
|
|
534
|
-
if (Number.isInteger(candidate.maxTokens) && candidate.maxTokens > 0) out.maxTokens = candidate.maxTokens
|
|
535
|
-
// A stored entry already speaks the profile's own vocabulary; a discovered one
|
|
536
|
-
// arrives with the adapter's (`inputModalities`), which is not a profile field.
|
|
537
|
-
if (Array.isArray(candidate.input) && candidate.input.length > 0) out.input = [...candidate.input]
|
|
538
|
-
else if (Array.isArray(candidate.inputModalities) && candidate.inputModalities.length > 0) out.input = [...candidate.inputModalities]
|
|
539
|
-
return out
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
// Interrogate the endpoint a create draft describes. Unlike a stored provider,
|
|
543
|
-
// the draft's fields — endpoint, protocol, and a key typed but not yet stored —
|
|
544
|
-
// are the only facts available, so they are what the request carries.
|
|
545
|
-
export async function discoverDraftModels(ctx, draft, { settingsNs, providerId } = {}) {
|
|
546
|
-
return fetchProviderModels(ctx, { provider: providerId, settingsNs }, {
|
|
547
|
-
baseURL: draft.baseURL,
|
|
548
|
-
api: draft.api,
|
|
549
|
-
apiKey: draft.key,
|
|
550
|
-
})
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
// ---- one model's entry ------------------------------------------------------
|
|
554
|
-
// The fields a saved model carries besides its id. A model the installed catalog
|
|
555
|
-
// describes inherits all of them; a model on a hand-declared route inherits none,
|
|
556
|
-
// which is why they have to be typeable.
|
|
557
|
-
|
|
558
|
-
const INPUT_MODALITIES = ['text only', 'text + image']
|
|
559
|
-
|
|
560
|
-
// A stored entry's `input` array as the row's choice value.
|
|
561
|
-
export function modelInputValue(model) {
|
|
562
|
-
return Array.isArray(model?.input) && model.input.includes('image') ? 'text + image' : 'text only'
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
function inputArray(value) {
|
|
566
|
-
return String(value ?? '').trim() === 'text + image' ? ['text', 'image'] : ['text']
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
// Capacities are typed the way a person says them, so `256K` and `1000` both
|
|
570
|
-
// parse; an empty field means "unset" (the adapter's own default applies) and
|
|
571
|
-
// anything else is a refusal rather than a silent zero.
|
|
572
|
-
export function parseCapacity(value) {
|
|
573
|
-
const text = String(value ?? '').trim().toLowerCase()
|
|
574
|
-
if (text === '') return undefined
|
|
575
|
-
const match = /^(\d+(?:\.\d+)?)([km])?$/.exec(text)
|
|
576
|
-
if (match === null) return Number.NaN
|
|
577
|
-
const scale = match[2] === 'k' ? 1000 : match[2] === 'm' ? 1000000 : 1
|
|
578
|
-
const parsed = Math.round(Number(match[1]) * scale)
|
|
579
|
-
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : Number.NaN
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
// ...and read back the same way, so a value that was typed as `256K` comes back
|
|
583
|
-
// as `256K` in the field without being rounded into a number that reads wrong.
|
|
584
|
-
export function formatCapacity(value) {
|
|
585
|
-
if (!Number.isInteger(value) || value <= 0) return ''
|
|
586
|
-
if (value % 1000000 === 0) return value / 1000000 + 'M'
|
|
587
|
-
if (value % 1000 === 0) return value / 1000 + 'K'
|
|
588
|
-
return String(value)
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
// The right-hand column of a model row: what the entry says beyond its id.
|
|
592
|
-
export function capacitySuffix(model) {
|
|
593
|
-
const parts = []
|
|
594
|
-
if (Number.isInteger(model?.contextWindow) && model.contextWindow > 0) parts.push(formatCapacity(model.contextWindow))
|
|
595
|
-
if (Number.isInteger(model?.maxTokens) && model.maxTokens > 0) parts.push(formatCapacity(model.maxTokens))
|
|
596
|
-
return parts.length === 0 ? '' : ' ' + parts.join(' · ')
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
// A stored entry as the editor's draft. `originalId` stays behind so the id can
|
|
600
|
-
// be corrected in place: the array is addressed by position, not by id.
|
|
601
|
-
export function modelDraftFrom(model) {
|
|
602
|
-
const entry = typeof model === 'object' && model !== null ? model : { id: String(model ?? '') }
|
|
603
|
-
return {
|
|
604
|
-
originalId: typeof entry.id === 'string' ? entry.id : '',
|
|
605
|
-
id: typeof entry.id === 'string' ? entry.id : '',
|
|
606
|
-
name: typeof entry.name === 'string' ? entry.name : '',
|
|
607
|
-
contextWindow: Number.isInteger(entry.contextWindow) ? entry.contextWindow : undefined,
|
|
608
|
-
maxTokens: Number.isInteger(entry.maxTokens) ? entry.maxTokens : undefined,
|
|
609
|
-
input: Array.isArray(entry.input) ? [...entry.input] : undefined,
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
// The model editor's rows. `id` is fixed while editing: the entry is addressed
|
|
614
|
-
// by position in the provider's list, and renaming it in place would silently
|
|
615
|
-
// change what the route serves rather than adding the model the user meant.
|
|
616
|
-
export function buildModelFormItems(draft, { isNew = false } = {}) {
|
|
617
|
-
const items = [{
|
|
618
|
-
kind: 'header',
|
|
619
|
-
label: isNew ? t('model.form.add') : t('model.form.edit', { id: draft.originalId || draft.id }),
|
|
620
|
-
value: '',
|
|
621
|
-
disabled: true,
|
|
622
|
-
}]
|
|
623
|
-
if (isNew) items.push({ kind: 'model-field', field: 'id', label: t('model.field.id'), value: draft.id, modelText: true })
|
|
624
|
-
else items.push({ kind: 'model-note', label: draft.originalId ?? '', wide: true, indent: 1, disabled: true, selectable: false })
|
|
625
|
-
items.push({ kind: 'model-field', field: 'name', label: t('model.field.name'), value: draft.name, modelText: true })
|
|
626
|
-
items.push({
|
|
627
|
-
kind: 'model-field',
|
|
628
|
-
field: 'contextWindow',
|
|
629
|
-
label: t('model.field.contextWindow'),
|
|
630
|
-
value: draft.contextWindow === undefined ? '' : formatCapacity(draft.contextWindow),
|
|
631
|
-
modelText: true,
|
|
632
|
-
})
|
|
633
|
-
items.push({
|
|
634
|
-
kind: 'model-field',
|
|
635
|
-
field: 'maxTokens',
|
|
636
|
-
label: t('model.field.maxTokens'),
|
|
637
|
-
value: draft.maxTokens === undefined ? '' : formatCapacity(draft.maxTokens),
|
|
638
|
-
modelText: true,
|
|
639
|
-
})
|
|
640
|
-
items.push({
|
|
641
|
-
kind: 'model-field',
|
|
642
|
-
field: 'input',
|
|
643
|
-
label: t('model.field.input'),
|
|
644
|
-
value: modelInputValue({ input: draft.input }),
|
|
645
|
-
options: INPUT_MODALITIES.map((id) => ({ label: tv(id), value: id })),
|
|
646
|
-
})
|
|
647
|
-
items.push({ kind: 'model-save', label: t('model.form.save'), value: 'Enter', disabled: false })
|
|
648
|
-
if (!isNew) items.push({ kind: 'model-remove', label: t('model.form.remove'), value: 'Enter', confirmText: t('model.form.removeConfirm'), disabled: false })
|
|
649
|
-
return items
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
// Whether an editor draft can be written, or why not. `existing` is every model
|
|
653
|
-
// the provider already lists except the row being edited, so a corrected id
|
|
654
|
-
// cannot collide with a sibling.
|
|
655
|
-
export function validateModelDraft(draft, { existing = [] } = {}) {
|
|
656
|
-
const id = String(draft.id ?? '').trim()
|
|
657
|
-
if (id === '') return t('model.error.idNeeded')
|
|
658
|
-
if (existing.some((model) => modelEntryId(model) === id)) return t('model.error.idDuplicate', { id })
|
|
659
|
-
if (Number.isNaN(parseCapacity(draft.contextWindow))) return t('model.error.capacity')
|
|
660
|
-
if (Number.isNaN(parseCapacity(draft.maxTokens))) return t('model.error.capacity')
|
|
661
|
-
return undefined
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
// The draft as a stored entry: only the fields that are set, because an empty
|
|
665
|
-
// number is "inherit the route default" and storing it as a value the schema
|
|
666
|
-
// rejects would break the whole profile.
|
|
667
|
-
export function modelFromDraft(draft) {
|
|
668
|
-
const entry = { id: String(draft.id ?? '').trim() }
|
|
669
|
-
const name = String(draft.name ?? '').trim()
|
|
670
|
-
if (name !== '') entry.name = name
|
|
671
|
-
const contextWindow = parseCapacity(draft.contextWindow)
|
|
672
|
-
if (contextWindow !== undefined) entry.contextWindow = contextWindow
|
|
673
|
-
const maxTokens = parseCapacity(draft.maxTokens)
|
|
674
|
-
if (maxTokens !== undefined) entry.maxTokens = maxTokens
|
|
675
|
-
if (Array.isArray(draft.input) && draft.input.length > 0) entry.input = [...draft.input]
|
|
676
|
-
return entry
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
// Write one editor draft over the provider's saved model list: replace the
|
|
680
|
-
// addressed row, or append a new one.
|
|
681
|
-
//
|
|
682
|
-
// `target.storedModels` is what the window loaded, so this is a full-array
|
|
683
|
-
// write against the revision the window opened at: a concurrent change from
|
|
684
|
-
// another surface is a conflict rather than a silent truncation.
|
|
685
|
-
export async function saveModelEntry(settings, target, draft) {
|
|
686
|
-
const next = [...target.storedModels]
|
|
687
|
-
const entry = modelFromDraft(draft)
|
|
688
|
-
if (target.index >= 0 && target.index < next.length) next[target.index] = entry
|
|
689
|
-
else next.push(entry)
|
|
690
|
-
await settings.mutate(target.settingsNs, [{
|
|
691
|
-
op: 'set',
|
|
692
|
-
path: [...target.providerPath, 'models'],
|
|
693
|
-
value: next,
|
|
694
|
-
}], target.revision)
|
|
695
|
-
return next
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
// Drop one row from the saved list. Removing the last one unsets the field
|
|
699
|
-
// rather than storing `[]`, so the patch carries no literal that a later
|
|
700
|
-
// catalog change would have to fight — for a route the installed catalog
|
|
701
|
-
// describes, unsetting is exactly "serve the catalog again".
|
|
702
|
-
//
|
|
703
|
-
// A hand-declared route has no catalog to fall back to, so the adapter refuses
|
|
704
|
-
// the write that would leave it with no models; the error surfaces to the
|
|
705
|
-
// caller instead of being papered over.
|
|
706
|
-
export async function removeModelEntry(settings, target) {
|
|
707
|
-
const next = target.storedModels.filter((_model, at) => at !== target.index)
|
|
708
|
-
const path = [...target.providerPath, 'models']
|
|
709
|
-
await settings.mutate(target.settingsNs, [next.length === 0 ? { op: 'unset', path } : { op: 'set', path, value: next }], target.revision)
|
|
710
|
-
return next
|
|
711
|
-
}
|
|
712
|
-
|
|
1
|
+
// Adding a model provider from the TUI: the WebUI's add flow, expressed as the
|
|
2
|
+
// settings dialog's own sub-views. The WebUI offers two ways to gain a provider
|
|
3
|
+
// — adopt one the adapter already ships, or hand-declare a route it does not —
|
|
4
|
+
// and this file owns both: the directory split that decides which routes are
|
|
5
|
+
// still addable, the create draft, its validation, and the write.
|
|
6
|
+
//
|
|
7
|
+
// Nothing here caches: the Model tab re-reads the settings descriptors and the
|
|
8
|
+
// llm directory on every render, so a provider added by another surface (the
|
|
9
|
+
// WebUI, a hand-edited patch) appears as soon as it is written.
|
|
10
|
+
//
|
|
11
|
+
// The host services are reached through `ctx.get()` and never imported, so the
|
|
12
|
+
// plugin still installs and boots on a host that ships none of them.
|
|
13
|
+
|
|
14
|
+
import z from '@deepseek-ai/schemastery'
|
|
15
|
+
import { t, tv } from './i18n.js'
|
|
16
|
+
import { formatError } from './util.js'
|
|
17
|
+
|
|
18
|
+
// Any route key walks a dict schema to the same profile node, so this one —
|
|
19
|
+
// which cannot occur in a provider id — addresses one profile's fields without
|
|
20
|
+
// resolving a real route.
|
|
21
|
+
const PROBE_ROUTE = '\u0000probe'
|
|
22
|
+
|
|
23
|
+
// A route id usable as a settings key AND as the stem of a credential name. The
|
|
24
|
+
// leading letter is the second half of that: `routeKeyRef` uppercases the id and
|
|
25
|
+
// replaces every non-alphanumeric run with `_`, and a credential reference is a
|
|
26
|
+
// POSIX shell identifier, which cannot start with a digit.
|
|
27
|
+
const ROUTE_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
|
|
28
|
+
|
|
29
|
+
// Families whose profile carries a per-route endpoint. A whole-section provider
|
|
30
|
+
// (DeepSeek, the account route) resolves its endpoint from its own Config, so it
|
|
31
|
+
// has no URL row of its own.
|
|
32
|
+
export const URL_FAMILIES = new Set(['llm-deepseek', 'llm-pi-ai'])
|
|
33
|
+
|
|
34
|
+
// The route the account family owns: its credential is a sign-in, not a stored
|
|
35
|
+
// key, so it gets no key row and no removal.
|
|
36
|
+
export const ACCOUNT_ROUTE = 'deepseek-account'
|
|
37
|
+
|
|
38
|
+
// The credential reference a route's key is stored under — the same
|
|
39
|
+
// `<ROUTE>_API_KEY` derivation the WebUI makes, so a key stored from either
|
|
40
|
+
// surface is found by both.
|
|
41
|
+
export function routeKeyRef(route) {
|
|
42
|
+
const id = String(route ?? '')
|
|
43
|
+
if (id === '') return ''
|
|
44
|
+
return id.toUpperCase().replace(/[^A-Z0-9]+/g, '_') + '_API_KEY'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A provider's stored model catalog tolerates both string entries and
|
|
48
|
+
// `{ id, ... }` objects; normalize to objects with an id so the merged view and
|
|
49
|
+
// the picker can treat them uniformly.
|
|
50
|
+
export function normalizeModels(models) {
|
|
51
|
+
if (!Array.isArray(models)) return []
|
|
52
|
+
const out = []
|
|
53
|
+
for (const entry of models) {
|
|
54
|
+
if (typeof entry === 'string' && entry) out.push({ id: entry })
|
|
55
|
+
else if (entry && typeof entry === 'object' && typeof entry.id === 'string' && entry.id) out.push(entry)
|
|
56
|
+
}
|
|
57
|
+
return out
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function modelEntryId(entry) {
|
|
61
|
+
return typeof entry === 'string' ? entry : entry?.id
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function valueAt(source, path) {
|
|
65
|
+
let value = source
|
|
66
|
+
for (const part of path) {
|
|
67
|
+
if (typeof value !== 'object' || value === null) return undefined
|
|
68
|
+
value = value[part]
|
|
69
|
+
}
|
|
70
|
+
return value
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function hasPath(source, path) {
|
|
74
|
+
let value = source
|
|
75
|
+
for (const part of path) {
|
|
76
|
+
if (typeof value !== 'object' || value === null || !Object.hasOwn(value, part)) return false
|
|
77
|
+
value = value[part]
|
|
78
|
+
}
|
|
79
|
+
return true
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Picker/choice annotation: a glanceable capability marker per model.
|
|
83
|
+
export function capabilitySuffix(model) {
|
|
84
|
+
const capabilities = []
|
|
85
|
+
if (Array.isArray(model.inputModalities) && model.inputModalities.includes('image')) capabilities.push('vision')
|
|
86
|
+
if (model.reasoning && Array.isArray(model.reasoning.efforts) && model.reasoning.efforts.length > 0) capabilities.push('thinking')
|
|
87
|
+
return capabilities.length > 0 ? ' (' + capabilities.join(', ') + ')' : ''
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Discover the models a provider profile advertises. Uses the model-discovery
|
|
91
|
+
// seam (`ctx.llm.discoverModels`): it answers from the installed catalog for a
|
|
92
|
+
// known route and interrogates a custom/dormant route's endpoint — resolving the
|
|
93
|
+
// stored credential itself, or using `record.apiKey` for a key the create form
|
|
94
|
+
// holds but has not stored yet. `listModels` only covers registered adapters, so
|
|
95
|
+
// it silently missed user-added providers that are not currently registered; it
|
|
96
|
+
// is kept as the fallback for namespaces that register no discovery (e.g. the
|
|
97
|
+
// built-in DeepSeek adapter).
|
|
98
|
+
//
|
|
99
|
+
// The refusal text is returned, not swallowed: "the endpoint answered 401" and
|
|
100
|
+
// "this protocol has no model listing" are the two answers a user can act on,
|
|
101
|
+
// and an empty list with no reason is what makes a fetch look broken.
|
|
102
|
+
export async function fetchProviderModels(ctx, entry, record) {
|
|
103
|
+
const llm = ctx.get('llm')
|
|
104
|
+
const source = typeof record === 'object' && record !== null ? record : {}
|
|
105
|
+
const text = (value) => (typeof value === 'string' ? value.trim() : '')
|
|
106
|
+
let fetched = null
|
|
107
|
+
let failure
|
|
108
|
+
if (llm?.discoverModels) {
|
|
109
|
+
const request = {
|
|
110
|
+
// An empty route id is not a route: omitting it lets the adapter treat the
|
|
111
|
+
// draft as endpoint-only instead of looking up "" in its catalog.
|
|
112
|
+
...(text(entry.provider) === '' ? {} : { provider: entry.provider }),
|
|
113
|
+
...(text(source.baseURL) === '' ? {} : { baseURL: source.baseURL }),
|
|
114
|
+
...(text(source.api) === '' ? {} : { api: source.api }),
|
|
115
|
+
// A key typed into the create form and not stored yet: asking with it is
|
|
116
|
+
// what makes adding a provider one pass instead of save-then-return.
|
|
117
|
+
...(text(source.apiKey) === '' ? {} : { apiKey: source.apiKey }),
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
fetched = await llm.discoverModels(entry.settingsNs, request)
|
|
121
|
+
} catch (error) {
|
|
122
|
+
// `NO_DISCOVERY` means the namespace registered no discovery; its
|
|
123
|
+
// registered adapter answers from its own catalog below.
|
|
124
|
+
if (error?.code !== 'NO_DISCOVERY') failure = formatError(error)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (!Array.isArray(fetched) && failure === undefined && llm?.listModels) {
|
|
128
|
+
try { fetched = await llm.listModels(entry.provider) } catch (error) { failure = formatError(error) }
|
|
129
|
+
}
|
|
130
|
+
// A callback that answered with something other than a list is a refusal too,
|
|
131
|
+
// even though nothing was thrown.
|
|
132
|
+
if (!Array.isArray(fetched) && failure === undefined) failure = t('model.fetchRefused')
|
|
133
|
+
return { models: normalizeModels(fetched), failed: failure !== undefined, error: failure }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Everything the Model tab and the add flows read, in one place: the settings
|
|
137
|
+
// service's redacted namespace views, the llm directory, and the two halves the
|
|
138
|
+
// Model tab splits that directory into — the routes whose profile resolves (a
|
|
139
|
+
// block of their own) and the ones it does not (the add picker's rows).
|
|
140
|
+
//
|
|
141
|
+
// A whole-section route (`settingsPath: []`) is always configured: its profile
|
|
142
|
+
// is the plugin entry's own Config, which exists whether or not the user has
|
|
143
|
+
// touched it. Getting that wrong is what hid DeepSeek from the Model tab on a
|
|
144
|
+
// fresh install, since the user layer of an untouched entry is empty.
|
|
145
|
+
//
|
|
146
|
+
// Returns undefined when the host exposes no settings service at all, which the
|
|
147
|
+
// caller renders as an explanatory row instead of an empty page.
|
|
148
|
+
export function providerDirectory(ctx) {
|
|
149
|
+
const settings = ctx.get('settings')
|
|
150
|
+
if (!settings) return undefined
|
|
151
|
+
let described
|
|
152
|
+
try { described = settings.describe({ redactSecrets: true }) } catch { return undefined }
|
|
153
|
+
const descriptors = new Map(described.map((entry) => [String(entry.ns), entry]))
|
|
154
|
+
const llm = ctx.get('llm')
|
|
155
|
+
const directory = llm?.listConfigurableProviders?.() ?? []
|
|
156
|
+
const configured = []
|
|
157
|
+
const addable = []
|
|
158
|
+
for (const entry of directory) {
|
|
159
|
+
const descriptor = descriptors.get(entry.settingsNs)
|
|
160
|
+
if (!descriptor) continue
|
|
161
|
+
const path = Array.isArray(entry.settingsPath) ? entry.settingsPath : []
|
|
162
|
+
const profile = path.length === 0 ? descriptor.value : valueAt(descriptor.value, path)
|
|
163
|
+
const row = { entry, descriptor, path, profile }
|
|
164
|
+
if (profile !== undefined) configured.push(row)
|
|
165
|
+
else addable.push(row)
|
|
166
|
+
}
|
|
167
|
+
return { settings, descriptors, llm, configured, addable }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Walk a rehydrated schema to the node a path addresses. Dict and array nodes
|
|
171
|
+
// have no `dict` of their own — every key walks to their shared `inner` node.
|
|
172
|
+
function nodeAtPath(root, path) {
|
|
173
|
+
let node = root
|
|
174
|
+
for (const key of path) {
|
|
175
|
+
if (node === undefined) return undefined
|
|
176
|
+
if (node.type === 'object') node = node.dict?.[key]
|
|
177
|
+
else if (node.type === 'dict' || node.type === 'array') node = node.inner
|
|
178
|
+
else return undefined
|
|
179
|
+
}
|
|
180
|
+
return node
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// The wire protocols a profile may name, read out of the owning namespace's own
|
|
184
|
+
// schema so the choices offered cannot drift from the ones the adapter accepts:
|
|
185
|
+
// both come from the same Config. Hardcoding them is how the TUI offered
|
|
186
|
+
// `google-generative-ai`, which this adapter family cannot serve at all.
|
|
187
|
+
export function protocolChoices(descriptor) {
|
|
188
|
+
const serialized = descriptor?.schema
|
|
189
|
+
if (!serialized) return []
|
|
190
|
+
let node
|
|
191
|
+
try {
|
|
192
|
+
node = nodeAtPath(new z(serialized), ['providers', PROBE_ROUTE, 'api'])
|
|
193
|
+
} catch { return [] }
|
|
194
|
+
if (node?.type !== 'union' || !Array.isArray(node.list)) return []
|
|
195
|
+
return node.list.map((entry) => entry.value).filter((value) => typeof value === 'string')
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// The namespace a hand-declared route is written into, with the revision and
|
|
199
|
+
// the protocol choices its own schema declares.
|
|
200
|
+
//
|
|
201
|
+
// Found by probe rather than by name: only a namespace whose schema describes
|
|
202
|
+
// `providers.<route>.api` can hold a declared route, so the first one that
|
|
203
|
+
// answers IS that namespace — however a profile happens to name its entry.
|
|
204
|
+
export function declareNamespace(directory) {
|
|
205
|
+
for (const [ns, descriptor] of directory.descriptors) {
|
|
206
|
+
const protocols = protocolChoices(descriptor)
|
|
207
|
+
if (protocols.length > 0) return { settingsNs: ns, revision: descriptor.revision, protocols }
|
|
208
|
+
}
|
|
209
|
+
return undefined
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// The add picker's rows. A catalog route the adapter ships has a single-letter
|
|
213
|
+
// name that means nothing to a user scanning 40 of them, so the top row is a
|
|
214
|
+
// filter: the list is what matches it.
|
|
215
|
+
//
|
|
216
|
+
// The custom entry sits above the catalog rather than after it — declaring an
|
|
217
|
+
// endpoint is what a user with an unfamilar gateway is looking for, and being
|
|
218
|
+
// 40 rows down is the same as absent.
|
|
219
|
+
export function providerPickerItems(directory, { query = '' } = {}) {
|
|
220
|
+
const items = [{
|
|
221
|
+
kind: 'provider-search',
|
|
222
|
+
label: t('provider.search'),
|
|
223
|
+
value: query === '' ? t('provider.searchPlaceholder') : query,
|
|
224
|
+
disabled: false,
|
|
225
|
+
}]
|
|
226
|
+
items.push({ kind: 'provider-pick-custom', label: t('provider.custom'), value: 'Enter', disabled: false })
|
|
227
|
+
items.push({ kind: 'header', label: t('provider.catalog'), value: '', disabled: true })
|
|
228
|
+
const rows = filterAddable(directory.addable, query)
|
|
229
|
+
if (rows.length === 0) {
|
|
230
|
+
items.push({
|
|
231
|
+
kind: 'provider-empty',
|
|
232
|
+
// "everything is configured" and "nothing matches what you typed" are
|
|
233
|
+
// different answers, and the second one invites a different next keystroke.
|
|
234
|
+
label: directory.addable.length === 0
|
|
235
|
+
? t('provider.catalogEmpty')
|
|
236
|
+
: t('provider.searchEmpty', { query: String(query).trim() }),
|
|
237
|
+
wide: true,
|
|
238
|
+
disabled: true,
|
|
239
|
+
selectable: false,
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
for (const row of rows) {
|
|
243
|
+
items.push({
|
|
244
|
+
kind: 'provider-pick',
|
|
245
|
+
label: row.entry.displayName,
|
|
246
|
+
// A hand-declared route's name is the user's own text; the route id is
|
|
247
|
+
// what identifies it in the settings and is worth showing beside it.
|
|
248
|
+
value: row.entry.declared === true ? row.entry.provider : '',
|
|
249
|
+
providerId: row.entry.provider,
|
|
250
|
+
settingsNs: row.entry.settingsNs,
|
|
251
|
+
revision: row.descriptor.revision,
|
|
252
|
+
disabled: false,
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
return items
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// The addable routes matching a picker query: the route id (what a user who
|
|
259
|
+
// knows the provider types) and its display name (what a user who does not reads).
|
|
260
|
+
export function filterAddable(addable, query) {
|
|
261
|
+
const needle = String(query ?? '').trim().toLowerCase()
|
|
262
|
+
if (needle === '') return addable
|
|
263
|
+
return addable.filter((row) => row.entry.provider.toLowerCase().includes(needle)
|
|
264
|
+
|| String(row.entry.displayName ?? '').toLowerCase().includes(needle))
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// A create draft starts from the picker's choice: adopting a route it already
|
|
268
|
+
// names, or declaring one from nothing.
|
|
269
|
+
export function createDraft(mode = 'catalog', route = '') {
|
|
270
|
+
return { mode, route, displayName: '', baseURL: '', api: '', key: '', models: [] }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// A route id derived from a display name.
|
|
274
|
+
//
|
|
275
|
+
// The route is a settings key and a credential stem, so it must be spelled a
|
|
276
|
+
// particular way — but the user is thinking in the name they see in their
|
|
277
|
+
// gateway's console. Typing the name first is the common order, so the form
|
|
278
|
+
// fills the key from it; only characters a route may carry survive, and when
|
|
279
|
+
// nothing usable is left this returns '' so validation still says why.
|
|
280
|
+
export function deriveRoute(displayName) {
|
|
281
|
+
const slug = String(displayName ?? '')
|
|
282
|
+
.toLowerCase()
|
|
283
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
284
|
+
.replace(/^-+|-+$/g, '')
|
|
285
|
+
// A credential reference cannot start with a digit, and neither may a route.
|
|
286
|
+
.replace(/^\d+/, '')
|
|
287
|
+
return ROUTE_PATTERN.test(slug) ? slug : ''
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// The create form's rows. One list for both modes so the shared fields (key,
|
|
291
|
+
// models) are built once; the mode decides the identity rows.
|
|
292
|
+
//
|
|
293
|
+
// `protocols` arrives resolved: the caller read it out of the schema, because a
|
|
294
|
+
// draft has no stored profile for this file to inspect. `title` names the route
|
|
295
|
+
// a catalog draft is adopting — the picker already chose it, so the form shows
|
|
296
|
+
// which one instead of asking again.
|
|
297
|
+
export function buildCreateFormItems(draft, { protocols = [], title } = {}) {
|
|
298
|
+
const custom = draft.mode === 'custom'
|
|
299
|
+
const items = [{
|
|
300
|
+
kind: 'header',
|
|
301
|
+
label: title ?? t(custom ? 'provider.form.custom' : 'provider.form.catalog'),
|
|
302
|
+
value: '',
|
|
303
|
+
disabled: true,
|
|
304
|
+
}]
|
|
305
|
+
if (custom) {
|
|
306
|
+
items.push({
|
|
307
|
+
kind: 'provider-field',
|
|
308
|
+
field: 'route',
|
|
309
|
+
label: t('provider.field.route'),
|
|
310
|
+
value: draft.route,
|
|
311
|
+
providerText: true,
|
|
312
|
+
})
|
|
313
|
+
items.push({ kind: 'provider-hint', label: routeHint(draft.route), wide: true, disabled: true, selectable: false })
|
|
314
|
+
items.push({ kind: 'provider-field', field: 'displayName', label: t('model.displayName'), value: draft.displayName, providerText: true })
|
|
315
|
+
}
|
|
316
|
+
items.push({ kind: 'provider-field', field: 'baseURL', label: t('model.providerUrl'), value: draft.baseURL, providerText: true })
|
|
317
|
+
if (custom) {
|
|
318
|
+
items.push({
|
|
319
|
+
kind: 'provider-field',
|
|
320
|
+
field: 'api',
|
|
321
|
+
label: t('model.wireProtocol'),
|
|
322
|
+
value: draft.api || protocols[0] || '',
|
|
323
|
+
options: protocols.map((id) => ({ label: protocolLabel(id), value: id })),
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
items.push({
|
|
327
|
+
kind: 'provider-field',
|
|
328
|
+
field: 'key',
|
|
329
|
+
label: t('model.providerApiKey'),
|
|
330
|
+
// The draft's key is never echoed: the row shows that one was typed, and
|
|
331
|
+
// the editor starts blank so Enter replaces rather than appends to it.
|
|
332
|
+
value: draft.key ? '••••••' : '',
|
|
333
|
+
secret: true,
|
|
334
|
+
})
|
|
335
|
+
const stored = draft.models.length
|
|
336
|
+
items.push({
|
|
337
|
+
kind: 'provider-field',
|
|
338
|
+
field: 'models',
|
|
339
|
+
label: t('model.models'),
|
|
340
|
+
value: stored > 0 ? t('model.selectedCount', { count: stored }) : t('model.fetch'),
|
|
341
|
+
providerModels: true,
|
|
342
|
+
})
|
|
343
|
+
for (const model of draft.models) {
|
|
344
|
+
items.push({
|
|
345
|
+
kind: 'provider-form-model',
|
|
346
|
+
label: model.id,
|
|
347
|
+
value: capabilitySuffix(model),
|
|
348
|
+
indent: 1,
|
|
349
|
+
disabled: true,
|
|
350
|
+
selectable: false,
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
items.push({ kind: 'provider-field', field: 'addModel', label: t('provider.field.addModel'), value: '', providerText: true })
|
|
354
|
+
items.push({ kind: 'provider-save', label: t('provider.field.save'), value: 'Enter', disabled: false })
|
|
355
|
+
return items
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// The route row's own hint: what an id may look like, and where its key will be
|
|
359
|
+
// stored. A rejected id is reported on save, not while typing — the TUI edits
|
|
360
|
+
// one row at a time and has no place for a live inline error.
|
|
361
|
+
function routeHint(route) {
|
|
362
|
+
if (route === '') return t('provider.hint.route', { ref: routeKeyRef('acme-gateway') })
|
|
363
|
+
if (!ROUTE_PATTERN.test(route)) return t('provider.error.route')
|
|
364
|
+
return t('provider.hint.route', { ref: routeKeyRef(route) })
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Protocol ids are technical tokens, but two of them read as words; the rest
|
|
368
|
+
// stay verbatim (see `tv`). Kept local so a new protocol needs no catalog work.
|
|
369
|
+
function protocolLabel(id) {
|
|
370
|
+
const label = tv(id)
|
|
371
|
+
return label === id ? id : label
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function isHttpUrl(value) {
|
|
375
|
+
try {
|
|
376
|
+
const protocol = new URL(String(value ?? '').trim()).protocol
|
|
377
|
+
return protocol === 'http:' || protocol === 'https:'
|
|
378
|
+
} catch {
|
|
379
|
+
return false
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Whether a draft can be written, or why it cannot. The message names the field
|
|
384
|
+
// the user is looking at rather than repeating the schema's own path-shaped
|
|
385
|
+
// refusal, which arrives only after the write has already begun.
|
|
386
|
+
//
|
|
387
|
+
// A catalog route needs nothing but a route: its profile inherits the adapter's
|
|
388
|
+
// own endpoint, protocol, and model catalog, so an empty profile is a valid way
|
|
389
|
+
// to add one. A hand-declared route has nothing to inherit — the three fields it
|
|
390
|
+
// cannot default (endpoint, protocol, at least one model) are required here.
|
|
391
|
+
export function validateCreateDraft(draft, { taken = [] } = {}) {
|
|
392
|
+
const route = String(draft.route ?? '').trim()
|
|
393
|
+
if (draft.mode === 'custom') {
|
|
394
|
+
if (!ROUTE_PATTERN.test(route)) return t('provider.error.route')
|
|
395
|
+
if (taken.includes(route)) return t('provider.error.taken')
|
|
396
|
+
const endpoint = String(draft.baseURL ?? '').trim()
|
|
397
|
+
if (endpoint === '') return t('provider.error.baseUrlNeeded')
|
|
398
|
+
if (!isHttpUrl(endpoint)) return t('provider.error.baseUrl')
|
|
399
|
+
if (!String(draft.api ?? '').trim()) return t('provider.error.apiNeeded')
|
|
400
|
+
if (draft.models.length === 0) return t('provider.error.modelsNeeded')
|
|
401
|
+
} else {
|
|
402
|
+
if (route === '') return t('provider.error.routeNeeded')
|
|
403
|
+
const endpoint = String(draft.baseURL ?? '').trim()
|
|
404
|
+
if (endpoint !== '' && !isHttpUrl(endpoint)) return t('provider.error.baseUrl')
|
|
405
|
+
}
|
|
406
|
+
const seen = new Set()
|
|
407
|
+
for (let index = 0; index < draft.models.length; index++) {
|
|
408
|
+
const id = String(modelEntryId(draft.models[index]) ?? '').trim()
|
|
409
|
+
if (id === '') return t('provider.error.modelId', { index: index + 1 })
|
|
410
|
+
if (seen.has(id)) return t('provider.error.modelDuplicate', { id })
|
|
411
|
+
seen.add(id)
|
|
412
|
+
}
|
|
413
|
+
return undefined
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Write a create draft: the whole profile at `providers.<route>` in one
|
|
417
|
+
// `settings.mutate`, then the key through the credentials service.
|
|
418
|
+
//
|
|
419
|
+
// The order is the WebUI's and it matters: a profile naming an `apiKeyEnv` it
|
|
420
|
+
// never stores is a route that cannot authenticate, while a stored key with no
|
|
421
|
+
// profile is an orphan the removal path would not clean up. The profile goes
|
|
422
|
+
// first, so a failed key write leaves a provider the user can retry without
|
|
423
|
+
// creating it twice.
|
|
424
|
+
//
|
|
425
|
+
// `revision` is the namespace revision the form opened at, so a route declared
|
|
426
|
+
// by another surface meanwhile is a conflict instead of an overwrite.
|
|
427
|
+
export async function saveCreateDraft(ctx, settings, draft, { settingsNs, revision } = {}) {
|
|
428
|
+
const route = String(draft.route ?? '').trim()
|
|
429
|
+
const displayName = String(draft.displayName ?? '').trim()
|
|
430
|
+
const endpoint = String(draft.baseURL ?? '').trim()
|
|
431
|
+
const api = String(draft.api ?? '').trim()
|
|
432
|
+
const key = String(draft.key ?? '').trim()
|
|
433
|
+
const profile = {}
|
|
434
|
+
// The profile names the conventional reference only when a key is about to be
|
|
435
|
+
// stored: a route added with the key left blank keeps its provider-native
|
|
436
|
+
// authentication (a credential chain, ADC) instead of resolving a reference
|
|
437
|
+
// nothing ever sets.
|
|
438
|
+
if (key !== '') profile.apiKeyEnv = routeKeyRef(route)
|
|
439
|
+
if (displayName !== '') profile.displayName = displayName
|
|
440
|
+
if (api !== '') profile.api = api
|
|
441
|
+
if (endpoint !== '') profile.baseURL = endpoint
|
|
442
|
+
if (draft.models.length > 0) profile.models = draft.models.map((model) => ({ ...model }))
|
|
443
|
+
await settings.mutate(settingsNs, [{ op: 'set', path: ['providers', route], value: profile }], revision)
|
|
444
|
+
if (key !== '') {
|
|
445
|
+
const credentials = ctx.get('credentials')
|
|
446
|
+
if (!credentials) throw new Error(t('model.credentialsUnavailable'))
|
|
447
|
+
await credentials.set(profile.apiKeyEnv, key)
|
|
448
|
+
}
|
|
449
|
+
return route
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// The model-selection rows for a create draft. `discovered` is the endpoint's
|
|
453
|
+
// answer, cached by the caller: a toggle must not spend a round trip, and the
|
|
454
|
+
// list must not shift under the cursor because the endpoint answered twice.
|
|
455
|
+
//
|
|
456
|
+
// The first row is the whole-selection toggle: a fresh fetch arrives with every
|
|
457
|
+
// candidate already picked (that is what "get the models" was for), so the one
|
|
458
|
+
// keystroke a user reliably needs is the way back out of that.
|
|
459
|
+
export function draftModelItems(draft, discovered, { failed = false, error, providerId } = {}) {
|
|
460
|
+
const items = []
|
|
461
|
+
if (error) {
|
|
462
|
+
// The refusal is the only answer a user can act on: 401 means the key, a
|
|
463
|
+
// bare host means the endpoint, "no listing" means type the ids by hand.
|
|
464
|
+
items.push({ kind: 'model-error', label: error, wide: true, indent: 1, tone: 'warning', disabled: true, selectable: false })
|
|
465
|
+
}
|
|
466
|
+
const candidates = normalizeModels(discovered)
|
|
467
|
+
const storedIds = new Set(draft.models.map((model) => modelEntryId(model)))
|
|
468
|
+
const seen = new Set()
|
|
469
|
+
const rows = []
|
|
470
|
+
const pushRow = (model) => {
|
|
471
|
+
const id = modelEntryId(model)
|
|
472
|
+
if (!id || seen.has(id)) return
|
|
473
|
+
seen.add(id)
|
|
474
|
+
const checked = storedIds.has(id)
|
|
475
|
+
// The saved entry is what carries capacities; the discovered one supplies
|
|
476
|
+
// them for a model the draft has not adopted yet.
|
|
477
|
+
const saved = draft.models.find((entry) => modelEntryId(entry) === id)
|
|
478
|
+
rows.push({
|
|
479
|
+
kind: 'model-toggle',
|
|
480
|
+
label: id + capabilitySuffix(model),
|
|
481
|
+
value: (checked ? '[x]' : '[ ]') + capacitySuffix(saved ?? model),
|
|
482
|
+
modelId: id,
|
|
483
|
+
checked,
|
|
484
|
+
draftToggle: true,
|
|
485
|
+
candidate: model,
|
|
486
|
+
providerId,
|
|
487
|
+
disabled: false,
|
|
488
|
+
})
|
|
489
|
+
}
|
|
490
|
+
for (const model of candidates) pushRow(model)
|
|
491
|
+
// Models the catalog no longer returns, or that the user typed by hand, stay
|
|
492
|
+
// toggleable so a stored selection is never a row the window cannot show.
|
|
493
|
+
for (const model of draft.models) pushRow(model)
|
|
494
|
+
if (candidates.length > 0) {
|
|
495
|
+
const picked = candidates.filter((model) => storedIds.has(modelEntryId(model))).length
|
|
496
|
+
items.push({
|
|
497
|
+
kind: 'model-select-all',
|
|
498
|
+
label: t('model.selectAll'),
|
|
499
|
+
value: (picked === candidates.length ? '[x] ' : '[ ] ') + picked + '/' + candidates.length,
|
|
500
|
+
allPicked: picked === candidates.length,
|
|
501
|
+
draftToggle: true,
|
|
502
|
+
providerId,
|
|
503
|
+
disabled: false,
|
|
504
|
+
})
|
|
505
|
+
}
|
|
506
|
+
items.push(...rows)
|
|
507
|
+
if (rows.length === 0) {
|
|
508
|
+
items.push({ label: failed ? t('model.noModels') : t('model.noModelsReturned'), value: '', disabled: true })
|
|
509
|
+
}
|
|
510
|
+
return items
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Every advertised model, merged into a saved list: an entry that is already
|
|
514
|
+
// there keeps its shape (and its position, so nothing the user corrected is
|
|
515
|
+
// rewritten), and a candidate joins with whatever the endpoint reported.
|
|
516
|
+
export function mergeModelSelection(stored, candidates) {
|
|
517
|
+
const next = [...stored]
|
|
518
|
+
for (const candidate of normalizeModels(candidates)) {
|
|
519
|
+
if (next.some((entry) => modelEntryId(entry) === candidate.id)) continue
|
|
520
|
+
next.push(adoptDiscovered(candidate) ?? { id: candidate.id })
|
|
521
|
+
}
|
|
522
|
+
return next
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Adopt one discovered model into a draft, keeping the metadata the endpoint
|
|
526
|
+
// advertised: without a context window and token cap a hand-declared model
|
|
527
|
+
// falls back to the route defaults, which is usually wrong for the model.
|
|
528
|
+
export function adoptDiscovered(model) {
|
|
529
|
+
const candidate = normalizeModels([model])[0]
|
|
530
|
+
if (!candidate) return undefined
|
|
531
|
+
const out = { id: candidate.id }
|
|
532
|
+
if (typeof candidate.name === 'string' && candidate.name && candidate.name !== candidate.id) out.name = candidate.name
|
|
533
|
+
if (Number.isInteger(candidate.contextWindow) && candidate.contextWindow > 0) out.contextWindow = candidate.contextWindow
|
|
534
|
+
if (Number.isInteger(candidate.maxTokens) && candidate.maxTokens > 0) out.maxTokens = candidate.maxTokens
|
|
535
|
+
// A stored entry already speaks the profile's own vocabulary; a discovered one
|
|
536
|
+
// arrives with the adapter's (`inputModalities`), which is not a profile field.
|
|
537
|
+
if (Array.isArray(candidate.input) && candidate.input.length > 0) out.input = [...candidate.input]
|
|
538
|
+
else if (Array.isArray(candidate.inputModalities) && candidate.inputModalities.length > 0) out.input = [...candidate.inputModalities]
|
|
539
|
+
return out
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Interrogate the endpoint a create draft describes. Unlike a stored provider,
|
|
543
|
+
// the draft's fields — endpoint, protocol, and a key typed but not yet stored —
|
|
544
|
+
// are the only facts available, so they are what the request carries.
|
|
545
|
+
export async function discoverDraftModels(ctx, draft, { settingsNs, providerId } = {}) {
|
|
546
|
+
return fetchProviderModels(ctx, { provider: providerId, settingsNs }, {
|
|
547
|
+
baseURL: draft.baseURL,
|
|
548
|
+
api: draft.api,
|
|
549
|
+
apiKey: draft.key,
|
|
550
|
+
})
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ---- one model's entry ------------------------------------------------------
|
|
554
|
+
// The fields a saved model carries besides its id. A model the installed catalog
|
|
555
|
+
// describes inherits all of them; a model on a hand-declared route inherits none,
|
|
556
|
+
// which is why they have to be typeable.
|
|
557
|
+
|
|
558
|
+
const INPUT_MODALITIES = ['text only', 'text + image']
|
|
559
|
+
|
|
560
|
+
// A stored entry's `input` array as the row's choice value.
|
|
561
|
+
export function modelInputValue(model) {
|
|
562
|
+
return Array.isArray(model?.input) && model.input.includes('image') ? 'text + image' : 'text only'
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function inputArray(value) {
|
|
566
|
+
return String(value ?? '').trim() === 'text + image' ? ['text', 'image'] : ['text']
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Capacities are typed the way a person says them, so `256K` and `1000` both
|
|
570
|
+
// parse; an empty field means "unset" (the adapter's own default applies) and
|
|
571
|
+
// anything else is a refusal rather than a silent zero.
|
|
572
|
+
export function parseCapacity(value) {
|
|
573
|
+
const text = String(value ?? '').trim().toLowerCase()
|
|
574
|
+
if (text === '') return undefined
|
|
575
|
+
const match = /^(\d+(?:\.\d+)?)([km])?$/.exec(text)
|
|
576
|
+
if (match === null) return Number.NaN
|
|
577
|
+
const scale = match[2] === 'k' ? 1000 : match[2] === 'm' ? 1000000 : 1
|
|
578
|
+
const parsed = Math.round(Number(match[1]) * scale)
|
|
579
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : Number.NaN
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ...and read back the same way, so a value that was typed as `256K` comes back
|
|
583
|
+
// as `256K` in the field without being rounded into a number that reads wrong.
|
|
584
|
+
export function formatCapacity(value) {
|
|
585
|
+
if (!Number.isInteger(value) || value <= 0) return ''
|
|
586
|
+
if (value % 1000000 === 0) return value / 1000000 + 'M'
|
|
587
|
+
if (value % 1000 === 0) return value / 1000 + 'K'
|
|
588
|
+
return String(value)
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// The right-hand column of a model row: what the entry says beyond its id.
|
|
592
|
+
export function capacitySuffix(model) {
|
|
593
|
+
const parts = []
|
|
594
|
+
if (Number.isInteger(model?.contextWindow) && model.contextWindow > 0) parts.push(formatCapacity(model.contextWindow))
|
|
595
|
+
if (Number.isInteger(model?.maxTokens) && model.maxTokens > 0) parts.push(formatCapacity(model.maxTokens))
|
|
596
|
+
return parts.length === 0 ? '' : ' ' + parts.join(' · ')
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// A stored entry as the editor's draft. `originalId` stays behind so the id can
|
|
600
|
+
// be corrected in place: the array is addressed by position, not by id.
|
|
601
|
+
export function modelDraftFrom(model) {
|
|
602
|
+
const entry = typeof model === 'object' && model !== null ? model : { id: String(model ?? '') }
|
|
603
|
+
return {
|
|
604
|
+
originalId: typeof entry.id === 'string' ? entry.id : '',
|
|
605
|
+
id: typeof entry.id === 'string' ? entry.id : '',
|
|
606
|
+
name: typeof entry.name === 'string' ? entry.name : '',
|
|
607
|
+
contextWindow: Number.isInteger(entry.contextWindow) ? entry.contextWindow : undefined,
|
|
608
|
+
maxTokens: Number.isInteger(entry.maxTokens) ? entry.maxTokens : undefined,
|
|
609
|
+
input: Array.isArray(entry.input) ? [...entry.input] : undefined,
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// The model editor's rows. `id` is fixed while editing: the entry is addressed
|
|
614
|
+
// by position in the provider's list, and renaming it in place would silently
|
|
615
|
+
// change what the route serves rather than adding the model the user meant.
|
|
616
|
+
export function buildModelFormItems(draft, { isNew = false } = {}) {
|
|
617
|
+
const items = [{
|
|
618
|
+
kind: 'header',
|
|
619
|
+
label: isNew ? t('model.form.add') : t('model.form.edit', { id: draft.originalId || draft.id }),
|
|
620
|
+
value: '',
|
|
621
|
+
disabled: true,
|
|
622
|
+
}]
|
|
623
|
+
if (isNew) items.push({ kind: 'model-field', field: 'id', label: t('model.field.id'), value: draft.id, modelText: true })
|
|
624
|
+
else items.push({ kind: 'model-note', label: draft.originalId ?? '', wide: true, indent: 1, disabled: true, selectable: false })
|
|
625
|
+
items.push({ kind: 'model-field', field: 'name', label: t('model.field.name'), value: draft.name, modelText: true })
|
|
626
|
+
items.push({
|
|
627
|
+
kind: 'model-field',
|
|
628
|
+
field: 'contextWindow',
|
|
629
|
+
label: t('model.field.contextWindow'),
|
|
630
|
+
value: draft.contextWindow === undefined ? '' : formatCapacity(draft.contextWindow),
|
|
631
|
+
modelText: true,
|
|
632
|
+
})
|
|
633
|
+
items.push({
|
|
634
|
+
kind: 'model-field',
|
|
635
|
+
field: 'maxTokens',
|
|
636
|
+
label: t('model.field.maxTokens'),
|
|
637
|
+
value: draft.maxTokens === undefined ? '' : formatCapacity(draft.maxTokens),
|
|
638
|
+
modelText: true,
|
|
639
|
+
})
|
|
640
|
+
items.push({
|
|
641
|
+
kind: 'model-field',
|
|
642
|
+
field: 'input',
|
|
643
|
+
label: t('model.field.input'),
|
|
644
|
+
value: modelInputValue({ input: draft.input }),
|
|
645
|
+
options: INPUT_MODALITIES.map((id) => ({ label: tv(id), value: id })),
|
|
646
|
+
})
|
|
647
|
+
items.push({ kind: 'model-save', label: t('model.form.save'), value: 'Enter', disabled: false })
|
|
648
|
+
if (!isNew) items.push({ kind: 'model-remove', label: t('model.form.remove'), value: 'Enter', confirmText: t('model.form.removeConfirm'), disabled: false })
|
|
649
|
+
return items
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Whether an editor draft can be written, or why not. `existing` is every model
|
|
653
|
+
// the provider already lists except the row being edited, so a corrected id
|
|
654
|
+
// cannot collide with a sibling.
|
|
655
|
+
export function validateModelDraft(draft, { existing = [] } = {}) {
|
|
656
|
+
const id = String(draft.id ?? '').trim()
|
|
657
|
+
if (id === '') return t('model.error.idNeeded')
|
|
658
|
+
if (existing.some((model) => modelEntryId(model) === id)) return t('model.error.idDuplicate', { id })
|
|
659
|
+
if (Number.isNaN(parseCapacity(draft.contextWindow))) return t('model.error.capacity')
|
|
660
|
+
if (Number.isNaN(parseCapacity(draft.maxTokens))) return t('model.error.capacity')
|
|
661
|
+
return undefined
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// The draft as a stored entry: only the fields that are set, because an empty
|
|
665
|
+
// number is "inherit the route default" and storing it as a value the schema
|
|
666
|
+
// rejects would break the whole profile.
|
|
667
|
+
export function modelFromDraft(draft) {
|
|
668
|
+
const entry = { id: String(draft.id ?? '').trim() }
|
|
669
|
+
const name = String(draft.name ?? '').trim()
|
|
670
|
+
if (name !== '') entry.name = name
|
|
671
|
+
const contextWindow = parseCapacity(draft.contextWindow)
|
|
672
|
+
if (contextWindow !== undefined) entry.contextWindow = contextWindow
|
|
673
|
+
const maxTokens = parseCapacity(draft.maxTokens)
|
|
674
|
+
if (maxTokens !== undefined) entry.maxTokens = maxTokens
|
|
675
|
+
if (Array.isArray(draft.input) && draft.input.length > 0) entry.input = [...draft.input]
|
|
676
|
+
return entry
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Write one editor draft over the provider's saved model list: replace the
|
|
680
|
+
// addressed row, or append a new one.
|
|
681
|
+
//
|
|
682
|
+
// `target.storedModels` is what the window loaded, so this is a full-array
|
|
683
|
+
// write against the revision the window opened at: a concurrent change from
|
|
684
|
+
// another surface is a conflict rather than a silent truncation.
|
|
685
|
+
export async function saveModelEntry(settings, target, draft) {
|
|
686
|
+
const next = [...target.storedModels]
|
|
687
|
+
const entry = modelFromDraft(draft)
|
|
688
|
+
if (target.index >= 0 && target.index < next.length) next[target.index] = entry
|
|
689
|
+
else next.push(entry)
|
|
690
|
+
await settings.mutate(target.settingsNs, [{
|
|
691
|
+
op: 'set',
|
|
692
|
+
path: [...target.providerPath, 'models'],
|
|
693
|
+
value: next,
|
|
694
|
+
}], target.revision)
|
|
695
|
+
return next
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Drop one row from the saved list. Removing the last one unsets the field
|
|
699
|
+
// rather than storing `[]`, so the patch carries no literal that a later
|
|
700
|
+
// catalog change would have to fight — for a route the installed catalog
|
|
701
|
+
// describes, unsetting is exactly "serve the catalog again".
|
|
702
|
+
//
|
|
703
|
+
// A hand-declared route has no catalog to fall back to, so the adapter refuses
|
|
704
|
+
// the write that would leave it with no models; the error surfaces to the
|
|
705
|
+
// caller instead of being papered over.
|
|
706
|
+
export async function removeModelEntry(settings, target) {
|
|
707
|
+
const next = target.storedModels.filter((_model, at) => at !== target.index)
|
|
708
|
+
const path = [...target.providerPath, 'models']
|
|
709
|
+
await settings.mutate(target.settingsNs, [next.length === 0 ? { op: 'unset', path } : { op: 'set', path, value: next }], target.revision)
|
|
710
|
+
return next
|
|
711
|
+
}
|
|
712
|
+
|