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.
Files changed (50) hide show
  1. package/README.md +21 -563
  2. package/bin/mcp-sse-bridge.mjs +499 -499
  3. package/docs/assets/conversation.png +0 -0
  4. package/docs/assets/cover.png +0 -0
  5. package/docs/assets/home.png +0 -0
  6. package/docs/assets/settings.png +0 -0
  7. package/docs/dsh-std-/346/216/245/345/205/245/350/257/264/346/230/216.md +566 -0
  8. package/docs/user-guide.md +616 -0
  9. package/docs//347/224/250/346/210/267/346/211/213/345/206/214.md +607 -593
  10. package/dsh-plugin.json +69 -69
  11. package/lib/boot-block.js +129 -0
  12. package/lib/diagnostics.js +162 -120
  13. package/lib/dsh-version.js +161 -0
  14. package/lib/git-branch.js +157 -157
  15. package/lib/i18n.js +283 -283
  16. package/lib/image.js +2 -1
  17. package/lib/index.js +5183 -4812
  18. package/lib/locales/ar.js +670 -635
  19. package/lib/locales/de.js +639 -604
  20. package/lib/locales/en.js +35 -0
  21. package/lib/locales/es.js +639 -604
  22. package/lib/locales/fr.js +639 -604
  23. package/lib/locales/id.js +641 -606
  24. package/lib/locales/it.js +639 -604
  25. package/lib/locales/ja.js +639 -604
  26. package/lib/locales/ko.js +639 -604
  27. package/lib/locales/nl.js +639 -604
  28. package/lib/locales/pl.js +653 -618
  29. package/lib/locales/pt-BR.js +639 -604
  30. package/lib/locales/ru.js +653 -618
  31. package/lib/locales/tr.js +639 -604
  32. package/lib/locales/vi.js +641 -606
  33. package/lib/locales/zh-CN.js +640 -605
  34. package/lib/locales/zh-TW.js +640 -605
  35. package/lib/markdown.js +101 -27
  36. package/lib/mcp-monitor.js +189 -0
  37. package/lib/mcp-skill.js +93 -93
  38. package/lib/mcp.js +865 -838
  39. package/lib/model-route.js +66 -0
  40. package/lib/providers.js +712 -712
  41. package/lib/screenshot.js +140 -0
  42. package/lib/splash-opencode.js +29 -26
  43. package/lib/startup.js +66 -48
  44. package/lib/term.js +53 -11
  45. package/lib/themes.js +101 -0
  46. package/lib/ui.js +685 -443
  47. package/lib/updates.js +5 -2
  48. package/lib/web-settings.js +52 -21
  49. package/package.json +160 -146
  50. 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
+