@goodandready/dsh-model-sync 0.2.7 → 0.2.10

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 CHANGED
@@ -14,6 +14,8 @@ OAuth, subscription, and tool-only routes are intentionally outside the scope.
14
14
 
15
15
  Runtime, scheduler, HTTP API, reconciliation, model allowlists, and Settings UI are implemented. The release workflow includes staging and production verification before publication.
16
16
 
17
+ The Settings UI groups synchronization controls, run summaries, configured providers, diagnostics, notifications, history, and editors into responsive cards that follow the DSH theme.
18
+
17
19
  ## Development
18
20
 
19
21
  See the project documentation under ``docs/`` for architecture, research, testing, and release notes.
@@ -45,7 +47,32 @@ Omit `provider` to discover all enabled API-key providers in one run. The Settin
45
47
  An empty `models` array means all models in the latest available catalog. The selected catalog is written to DSH's `llm-pi-ai` settings, so the standard DSH model picker refreshes from the resulting list.
46
48
 
47
49
 
50
+ The plugin-owned `adapterRegistry` setting adds an opt-in declarative adapter without copying code. A provider entry can specify `endpoint`, `auth` (`bearer`, `x-api-key`, `query-key`, or `none`), `parser`, `modelsPath`, field paths, and explicit capability mappings. Endpoints must be HTTPS except loopback HTTP; credential-like static headers and URL query/fragment data are rejected. Existing built-in descriptors and OpenAI-compatible routes remain the default. Runtime integrations can register an explicit adapter implementation through the registry escape hatch, provided it implements `discover` and `health`.
51
+
48
52
  The full catalog discovered by an applied run is cached in the plugin's own settings
49
53
  separately from the model allowlist. After a restart, the model chooser can show the
50
54
  full last-known catalog while the standard DSH picker still exposes only the selected
51
55
  models. Dry-run remains read-only and does not persist this cache.
56
+
57
+
58
+ `GET /dsh-model-sync/report` returns the latest stable aggregate report. It distinguishes success, partial success, failure, and empty runs, includes provider/model counts, and labels whether the source was manual or scheduled. `GET /dsh-model-sync/notifications` returns the bounded in-app notification ledger; repeated identical failures update an occurrence counter instead of creating noise. `POST /dsh-model-sync/notifications/read` and `POST /dsh-model-sync/notifications/acknowledge` accept `{ "id": "sync-notice-..." }` and persist the corresponding state for applied runs. Report and notification text is redacted before it leaves the synchronizer; no webhook or MCP dependency is required.
59
+
60
+ `GET /dsh-model-sync/credentials` returns bounded diagnostics for configured provider credential references. It calls the DSH credentials `describe` seam only, reports masked reference labels, source/configured state, rotation order, and the last safe request outcome. It never resolves or returns a credential value. `POST /dsh-model-sync/credentials/check` runs the health probe and returns the same diagnostics in one response; a failed reference is recorded per provider without hiding the remaining rotation entries.
61
+
62
+ `GET /dsh-model-sync/history` returns the bounded history of applied runs. Use
63
+ `?provider=<id>&details=true` for one provider's full snapshots and metadata diff.
64
+ `POST /dsh-model-sync/history/rollback` accepts `{ "historyId": "sync-…", "provider": "openai" }`
65
+ and restores only that provider's catalog; it never changes the model allowlist.
66
+ History is written only by non-dry-run synchronization, is retained according to
67
+ `historyLimit` (1–200, default 50), and redacts credential-shaped error values.
68
+ Rename detection requires an explicit provider `aliases` or `previousIds` field;
69
+ model-id similarity is never guessed.
70
+
71
+
72
+ Lifecycle protection keeps a model `stale` after a missing discovery and only
73
+ moves it to `removed` after the configured `staleGraceRuns` and an explicit
74
+ removal confirmation. Provider-declared `deprecated`/`deprecationDate`/
75
+ `expiration_date` metadata is retained as an audit status. Repeated transport or
76
+ credential failures do not advance lifecycle counters. The full catalog cache
77
+ retains stale entries until the user confirms removal; the model picker defaults
78
+ to active models while explicit selections may still name a retained model.
@@ -1,7 +1,12 @@
1
+ import { catalogRequestError } from './reliability.js'
1
2
  import { canUseGenericAdapter, createGenericAdapter } from './generic-adapter.js'
2
3
  import { normalizeModels } from './models.js'
3
4
 
4
5
  const MAX_RESPONSE_BYTES = 4 * 1024 * 1024
6
+ const SAFE_AUTH = new Set(['bearer', 'x-api-key', 'query-key', 'none'])
7
+ const SAFE_PARSERS = new Set(['openai', 'google'])
8
+ const CAPABILITIES = new Set(['vision', 'tools', 'reasoning', 'embeddings'])
9
+ const BLOCKED_HEADERS = /authorization|api[_-]?key|token|secret|cookie/i
5
10
 
6
11
  const PROVIDER_ADAPTERS = Object.freeze({
7
12
  anthropic: { endpoint: 'https://api.anthropic.com/v1/models', auth: 'x-api-key', headers: { 'anthropic-version': '2023-06-01' } },
@@ -16,13 +21,13 @@ const PROVIDER_ADAPTERS = Object.freeze({
16
21
  cerebras: { endpoint: 'https://api.cerebras.ai/v1/models', auth: 'bearer' },
17
22
  fireworks: { endpoint: 'https://api.fireworks.ai/inference/v1/models', auth: 'bearer' },
18
23
  huggingface: { endpoint: 'https://router.huggingface.co/v1/models', auth: 'bearer' },
19
- 'moonshotai': { endpoint: 'https://api.moonshot.ai/v1/models', auth: 'bearer' },
24
+ moonshotai: { endpoint: 'https://api.moonshot.ai/v1/models', auth: 'bearer' },
20
25
  'moonshotai-cn': { endpoint: 'https://api.moonshot.cn/v1/models', auth: 'bearer' },
21
26
  nvidia: { endpoint: 'https://integrate.api.nvidia.com/v1/models', auth: 'bearer' },
22
27
  'qwen-token-plan': { endpoint: 'https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/models', auth: 'bearer' },
23
28
  'qwen-token-plan-cn': { endpoint: 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1/models', auth: 'bearer' },
24
29
  together: { endpoint: 'https://api.together.ai/v1/models', auth: 'bearer' },
25
- 'xiaomi': { endpoint: 'https://api.xiaomimimo.com/v1/models', auth: 'bearer' },
30
+ xiaomi: { endpoint: 'https://api.xiaomimimo.com/v1/models', auth: 'bearer' },
26
31
  'xiaomi-token-plan-ams': { endpoint: 'https://token-plan-ams.xiaomimimo.com/v1/models', auth: 'bearer' },
27
32
  'xiaomi-token-plan-cn': { endpoint: 'https://token-plan-cn.xiaomimimo.com/v1/models', auth: 'bearer' },
28
33
  'xiaomi-token-plan-sgp': { endpoint: 'https://token-plan-sgp.xiaomimimo.com/v1/models', auth: 'bearer' },
@@ -30,8 +35,32 @@ const PROVIDER_ADAPTERS = Object.freeze({
30
35
  'zai-coding-cn': { endpoint: 'https://open.bigmodel.cn/api/coding/paas/v4/models', auth: 'bearer' },
31
36
  })
32
37
 
33
- function parseRows(provider, payload, parser) {
34
- if (parser === 'google') {
38
+ function pathValue(value, path) {
39
+ if (typeof path !== 'string' || !path.trim()) return undefined
40
+ return path.split('.').reduce((current, key) => current && typeof current === 'object' ? current[key] : undefined, value)
41
+ }
42
+
43
+ function mappedRows(provider, rows, descriptor) {
44
+ const fields = descriptor.fields && typeof descriptor.fields === 'object' ? descriptor.fields : {}
45
+ const capabilityMap = descriptor.capabilityMap && typeof descriptor.capabilityMap === 'object' ? descriptor.capabilityMap : {}
46
+ return rows.map((row) => {
47
+ const mapped = { ...(row && typeof row === 'object' ? row : {}) }
48
+ for (const [target, source] of Object.entries(fields)) {
49
+ if (!['id', 'name', 'description', 'contextWindow', 'maxTokens', 'pricing', 'tags'].includes(target)) continue
50
+ const value = pathValue(row, source)
51
+ if (value !== undefined) mapped[target] = value
52
+ }
53
+ for (const capability of CAPABILITIES) {
54
+ const sources = Array.isArray(capabilityMap[capability]) ? capabilityMap[capability] : [capabilityMap[capability]]
55
+ const source = sources.find((item) => typeof item === 'string' && pathValue(row, item) !== undefined)
56
+ if (source) mapped[capability] = pathValue(row, source)
57
+ }
58
+ return mapped
59
+ })
60
+ }
61
+
62
+ function parseRows(provider, payload, descriptor) {
63
+ if (descriptor.parse === 'google') {
35
64
  const rows = Array.isArray(payload?.models) ? payload.models : []
36
65
  return normalizeModels(provider, rows.map((row) => ({
37
66
  ...row,
@@ -41,47 +70,138 @@ function parseRows(provider, payload, parser) {
41
70
  maxTokens: row.outputTokenLimit,
42
71
  })))
43
72
  }
44
- const rows = payload?.data ?? payload?.models ?? payload?.availableModels
45
- return normalizeModels(provider, Array.isArray(rows) ? rows : [])
73
+ const rows = pathValue(payload, descriptor.modelsPath) ?? payload?.data ?? payload?.models ?? payload?.availableModels
74
+ return normalizeModels(provider, Array.isArray(rows) ? mappedRows(provider, rows, descriptor) : [])
75
+ }
76
+
77
+ function privateHttpHost(hostname) {
78
+ const host = String(hostname ?? '').toLowerCase()
79
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1'
80
+ }
81
+
82
+ export function normalizeAdapterConfig(config, profile = {}) {
83
+ const source = config && typeof config === 'object' ? config : {}
84
+ const base = profile.baseURL ? String(profile.baseURL).replace(/\/+$/, '') : ''
85
+ const endpointValue = source.endpoint || (base ? (base.endsWith('/models') ? base : base + '/models') : '')
86
+ if (typeof endpointValue !== 'string' || !endpointValue.trim()) throw Object.assign(new Error('adapter endpoint is required'), { code: 'ADAPTER_CONFIG_INVALID' })
87
+ let endpoint
88
+ try { endpoint = new URL(endpointValue) } catch { throw Object.assign(new Error('adapter endpoint must be a valid URL'), { code: 'ADAPTER_CONFIG_INVALID' }) }
89
+ if (endpoint.username || endpoint.password || endpoint.search || endpoint.hash || !['https:', 'http:'].includes(endpoint.protocol)) {
90
+ throw Object.assign(new Error('adapter endpoint must not contain credentials, query, or hash'), { code: 'ADAPTER_CONFIG_INVALID' })
91
+ }
92
+ if (endpoint.protocol === 'http:' && !privateHttpHost(endpoint.hostname)) {
93
+ throw Object.assign(new Error('adapter endpoint must use https outside loopback'), { code: 'ADAPTER_CONFIG_INVALID' })
94
+ }
95
+ const auth = source.auth === undefined ? 'bearer' : String(source.auth)
96
+ if (!SAFE_AUTH.has(auth)) throw Object.assign(new Error('adapter auth mode is unsupported'), { code: 'ADAPTER_CONFIG_INVALID' })
97
+ const parse = source.parser === undefined ? 'openai' : String(source.parser)
98
+ if (!SAFE_PARSERS.has(parse)) throw Object.assign(new Error('adapter parser is unsupported'), { code: 'ADAPTER_CONFIG_INVALID' })
99
+ const headers = {}
100
+ if (source.headers !== undefined) {
101
+ if (!source.headers || typeof source.headers !== 'object' || Array.isArray(source.headers)) throw Object.assign(new Error('adapter headers must be an object'), { code: 'ADAPTER_CONFIG_INVALID' })
102
+ for (const [key, value] of Object.entries(source.headers).slice(0, 20)) {
103
+ if (!/^[A-Za-z0-9-]{1,64}$/.test(key) || BLOCKED_HEADERS.test(key) || typeof value !== 'string' || value.length > 256 || !/^[\x20-\x7E]*$/.test(value)) {
104
+ throw Object.assign(new Error('adapter headers contain an unsafe field'), { code: 'ADAPTER_CONFIG_INVALID' })
105
+ }
106
+ headers[key] = value
107
+ }
108
+ }
109
+ const modelsPath = typeof source.modelsPath === 'string' && /^[A-Za-z0-9_.-]{1,128}$/.test(source.modelsPath.trim()) ? source.modelsPath.trim() : 'data'
110
+ const capabilityMap = source.capabilityMap && typeof source.capabilityMap === 'object' && !Array.isArray(source.capabilityMap) ? structuredClone(source.capabilityMap) : {}
111
+ for (const key of Object.keys(capabilityMap)) if (!CAPABILITIES.has(key)) delete capabilityMap[key]
112
+ const fields = source.fields && typeof source.fields === 'object' && !Array.isArray(source.fields) ? structuredClone(source.fields) : {}
113
+ for (const key of Object.keys(fields)) if (!['id', 'name', 'description', 'contextWindow', 'maxTokens', 'pricing', 'tags'].includes(key) || typeof fields[key] !== 'string' || fields[key].length > 128) delete fields[key]
114
+ for (const key of Object.keys(capabilityMap)) {
115
+ const values = Array.isArray(capabilityMap[key]) ? capabilityMap[key] : [capabilityMap[key]]
116
+ capabilityMap[key] = values.filter((value) => typeof value === 'string' && value.length <= 128).slice(0, 8)
117
+ }
118
+ return { endpoint: endpoint.toString(), auth, parse, headers, modelsPath, capabilityMap, fields }
46
119
  }
47
120
 
48
- async function fetchSpecific(profile, descriptor, { resolveCredential, fetchImpl, signal }) {
121
+ async function requestSpecific(profile, descriptor, { resolveCredential, fetchImpl, signal }) {
49
122
  const apiKey = profile.apiKeyEnv ? await resolveCredential(profile.apiKeyEnv) : ''
50
- const url = new URL(profile.baseURL ? `${String(profile.baseURL).replace(/\/+$/, '')}/models` : descriptor.endpoint)
123
+ const url = new URL(descriptor.endpoint)
51
124
  const headers = { Accept: 'application/json', ...(descriptor.headers ?? {}) }
52
- if (descriptor.auth === 'bearer' && apiKey) headers.Authorization = `Bearer ${apiKey}`
125
+ if (descriptor.auth === 'bearer' && apiKey) headers.Authorization = 'Bearer ' + apiKey
53
126
  if (descriptor.auth === 'x-api-key' && apiKey) headers['x-api-key'] = apiKey
54
127
  if (descriptor.auth === 'query-key' && apiKey) url.searchParams.set('key', apiKey)
55
- const response = await fetchImpl(url, { headers, signal })
56
- if (!response.ok) throw new Error(`model catalog request failed with HTTP ${response.status}`)
128
+ let response
129
+ try { response = await fetchImpl(url, { headers, signal }) } catch (error) {
130
+ if (error && typeof error === 'object') error.adapterCode = 'endpoint'
131
+ throw error
132
+ }
133
+ if (!response.ok) {
134
+ const error = catalogRequestError(response.status)
135
+ error.adapterCode = [401, 403].includes(response.status) ? 'auth' : 'http'
136
+ throw error
137
+ }
138
+ return response
139
+ }
140
+
141
+ async function fetchSpecific(profile, descriptor, options) {
142
+ const response = await requestSpecific(profile, descriptor, options)
57
143
  const text = await response.text()
58
- if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_BYTES) throw new Error('model catalog response is too large')
144
+ if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_BYTES) {
145
+ const error = new Error('model catalog response is too large')
146
+ error.adapterCode = 'schema'
147
+ throw error
148
+ }
59
149
  let payload
60
- try {
61
- payload = JSON.parse(text)
62
- } catch {
63
- throw new Error('model catalog response is not valid JSON')
150
+ try { payload = JSON.parse(text) } catch {
151
+ const error = new Error('model catalog response is not valid JSON')
152
+ error.adapterCode = 'schema'
153
+ throw error
64
154
  }
65
- return parseRows(profile.provider, payload, descriptor.parse)
155
+ return parseRows(profile.provider, payload, descriptor)
156
+ }
157
+
158
+ async function probeSpecific(profile, descriptor, options) {
159
+ const startedAt = Date.now()
160
+ const response = await requestSpecific(profile, descriptor, options)
161
+ if (typeof response.body?.cancel === 'function') await response.body.cancel()
162
+ return { statusCode: response.status, latencyMs: Date.now() - startedAt }
66
163
  }
67
164
 
68
- export function createProviderAdapterRegistry({ resolveCredential, fetchImpl = fetch } = {}) {
165
+ export function createProviderAdapterRegistry({ resolveCredential, fetchImpl = fetch, adapters = {} } = {}) {
69
166
  const generic = createGenericAdapter({ resolveCredential, fetchImpl })
70
- return Object.freeze({
167
+ const explicit = new Map()
168
+ for (const [id, adapter] of Object.entries(adapters ?? {})) {
169
+ if (adapter?.discover && adapter?.health) explicit.set(id, Object.freeze({ id: 'explicit:' + id, canHandle: adapter.canHandle ?? (() => true), discover: adapter.discover, health: adapter.health }))
170
+ }
171
+ function register(id, adapter) {
172
+ if (typeof id !== 'string' || !/^[A-Za-z0-9._:-]{1,64}$/.test(id) || !adapter?.discover || !adapter?.health) {
173
+ throw Object.assign(new Error('adapter implementation must expose discover and health'), { code: 'ADAPTER_IMPLEMENTATION_INVALID' })
174
+ }
175
+ const normalized = Object.freeze({ id: 'explicit:' + id, canHandle: adapter.canHandle ?? (() => true), discover: adapter.discover, health: adapter.health })
176
+ explicit.set(id, normalized)
177
+ return normalized
178
+ }
179
+ function declarative(profile, config) {
180
+ const descriptor = normalizeAdapterConfig(config, profile)
181
+ return Object.freeze({
182
+ id: 'declarative:' + profile.provider,
183
+ canHandle: () => true,
184
+ discover: (current, options = {}) => fetchSpecific(current, descriptor, { resolveCredential, fetchImpl, ...options }),
185
+ health: (current, options = {}) => probeSpecific(current, descriptor, { resolveCredential, fetchImpl, ...options }),
186
+ descriptor,
187
+ })
188
+ }
189
+ const registry = {
71
190
  descriptors: PROVIDER_ADAPTERS,
72
- select(profile) {
191
+ register,
192
+ select(profile, config) {
193
+ if (profile?.adapterId && explicit.has(profile.adapterId)) return explicit.get(profile.adapterId)
194
+ if (config && config.enabled !== false && (config.endpoint || profile?.baseURL)) return declarative(profile, config)
73
195
  if (canUseGenericAdapter(profile)) return generic
74
196
  const descriptor = PROVIDER_ADAPTERS[profile?.provider]
75
197
  if (!descriptor) return null
76
198
  return Object.freeze({
77
- id: `provider:${profile.provider}`,
199
+ id: 'provider:' + profile.provider,
78
200
  canHandle: () => true,
79
- discover: (current, options = {}) => fetchSpecific(current, descriptor, {
80
- resolveCredential,
81
- fetchImpl,
82
- ...options,
83
- }),
201
+ discover: (current, options = {}) => fetchSpecific(current, { ...descriptor, modelsPath: 'data', fields: {}, capabilityMap: {} }, { resolveCredential, fetchImpl, ...options }),
202
+ health: (current, options = {}) => probeSpecific(current, descriptor, { resolveCredential, fetchImpl, ...options }),
84
203
  })
85
204
  },
86
- })
205
+ }
206
+ return Object.freeze(registry)
87
207
  }
package/lib/client.js CHANGED
@@ -4,11 +4,46 @@ window.__ModuleLoader__.load({
4
4
  var module = { exports: {} }
5
5
  var exports = module.exports
6
6
  const React = require('react')
7
+
8
+ const CSS = '.dms-wrap{display:flex;flex-direction:column;gap:14px;max-width:760px;box-sizing:border-box;color:var(--dsw-alias-label-primary,inherit)}' +
9
+ '.dms-wrap>p{margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.55}' +
10
+ '.dms-toolbar,.dms-section,.dms-editor{display:flex;flex-direction:row;align-items:center;flex-wrap:wrap;gap:10px;padding:13px 14px;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-primary,rgba(255,255,255,.14)));border-radius:10px;background:var(--dsw-alias-bg-layer-1,transparent);box-sizing:border-box}' +
11
+ '.dms-toolbar>select{flex:1 1 190px;min-width:190px}' +
12
+ '.dms-toolbar>.dms-actions{flex-basis:100%}' +
13
+ '.dms-toolbar>label{display:inline-flex;align-items:flex-start;gap:7px;font-size:12px;line-height:1.35;color:var(--dsw-alias-label-secondary)}' +
14
+ '.dms-toolbar>select,.dms-editor input,.dms-editor textarea{box-sizing:border-box;min-width:0;background:var(--dsw-specific-input-major,var(--dsw-alias-bg-layer-2,transparent));border:1px solid var(--dsw-alias-border-l1,var(--dsw-alias-border-l2));border-radius:6px;color:var(--dsw-alias-label-primary);font:inherit;padding:7px 9px}' +
15
+ '.dms-toolbar>label input{accent-color:var(--dsw-alias-brand-primary,var(--dsw-alias-button-info-fill))}' +
16
+ '.dms-actions{display:flex;align-items:center;flex-wrap:wrap;gap:7px}' +
17
+ '.dms-wrap>.dms-actions{padding:10px 14px;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-primary,rgba(255,255,255,.14)));border-radius:10px;background:var(--dsw-alias-bg-layer-1,transparent)}' +
18
+ '.dms-btn{margin:0!important;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-primary));border-radius:6px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:1.35;padding:6px 10px;cursor:pointer;white-space:normal}' +
19
+ '.dms-btn:hover:not(:disabled){border-color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-bg-layer-2,rgba(255,255,255,.05))}' +
20
+ '.dms-btn:disabled{opacity:.45;cursor:default}' +
21
+ '.dms-section>strong{display:block;font-size:13px;font-weight:650}' +
22
+ '.dms-provider{display:grid!important;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:10px;padding:10px 11px;margin:0 0 7px;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-primary));border-radius:8px;background:var(--dsw-alias-bg-layer-2,transparent)}' +
23
+ '.dms-provider>span{min-width:0;overflow-wrap:anywhere;line-height:1.4}' +
24
+ '.dms-section>div[style]{padding:8px 0;border-top:1px solid var(--dsw-alias-border-l2)}' +
25
+ '.dms-editor{border-color:var(--dsw-alias-brand-primary,var(--dsw-alias-border-l2))}' +
26
+ '.dms-editor strong{font-size:13px}' +
27
+ '.dms-editor label{display:block;gap:5px;margin-top:8px;color:var(--dsw-alias-label-secondary);font-size:12px}' +
28
+ '.dms-editor label input,.dms-editor label textarea{width:100%}' +
29
+ '.dms-editor input[type="checkbox"]{width:auto;accent-color:var(--dsw-alias-brand-primary)}' +
30
+ '@media (max-width:540px){.dms-provider{grid-template-columns:1fr!important}.dms-provider .dms-btn{justify-self:start}.dms-toolbar>div:first-child{align-items:stretch;flex-direction:column}.dms-toolbar>div:first-child select{width:100%}.dms-toolbar,.dms-section,.dms-editor{padding:11px 12px}}'
31
+
32
+ function ensureStyles() {
33
+ if (typeof document === 'undefined' || !document.head) return
34
+ const cssId = 'dsh-model-sync/settings.module.css'
35
+ if (document.querySelector('style[data-plugin-css="' + cssId + '"]')) return
36
+ const tag = document.createElement('style')
37
+ tag.textContent = CSS
38
+ tag.setAttribute('data-plugin', 'dsh-model-sync')
39
+ tag.setAttribute('data-plugin-css', cssId)
40
+ document.head.appendChild(tag)
41
+ }
7
42
  const en = {
8
- title: 'Model synchronization', desc: 'Refresh model catalogs for API-key providers. A dry-run is the default; applying changes is explicit.', loading: 'Loading…', refresh: 'Refresh status', refreshAll: 'Refresh all', run: 'Discover', apply: 'Apply changes', dryRun: 'Preview only (dry-run)', all: 'All API-key providers', ok: 'ready', dormant: 'not configured', running: 'running…', noProviders: 'No API-key providers found.', lastRun: 'Last run', selectModels: 'Choose models', saveSelection: 'Save and apply selection', selectAtLeastOne: 'Select at least one model or use All models.', cancel: 'Cancel', selected: 'selected', allModels: 'All models', noModels: 'No models in the current catalog.', pickerNote: 'The selection is applied to the DSH model picker immediately.',
43
+ title: 'Model synchronization', desc: 'Refresh model catalogs for API-key providers. A dry-run is the default; applying changes is explicit.', loading: 'Loading…', refresh: 'Refresh status', refreshAll: 'Refresh all', run: 'Discover', apply: 'Apply changes', dryRun: 'Preview only (dry-run)', all: 'All API-key providers', ok: 'ready', dormant: 'not configured', running: 'running…', noProviders: 'No API-key providers found.', lastRun: 'Last run', selectModels: 'Choose models', saveSelection: 'Save and apply selection', selectAtLeastOne: 'Select at least one model or use All models.', cancel: 'Cancel', selected: 'selected', allModels: 'All models', noModels: 'No models in the current catalog.', pickerNote: 'The selection is applied to the DSH model picker immediately.', health: 'Check availability', healthOk: 'available', healthError: 'unavailable', healthDormant: 'not configured', policy: 'Model policy', savePolicy: 'Save policy', policyInclude: 'Include patterns (one per line)', policyExclude: 'Exclude patterns (one per line)', policyRequire: 'Required capabilities (comma-separated)', policyDeny: 'Denied capabilities (comma-separated)', policyNote: 'Patterns match model id, name, and tags. Apply changes to update the DSH picker.', scheduler: 'Scheduler', nextRun: 'next run', schedulerOff: 'off', schedulerOn: 'on', removeMissing: 'Confirm stale removal', stale: 'stale', deprecated: 'deprecated', history: 'Synchronization history', historyRun: 'Run', diffAdded: 'added', diffRemoved: 'removed', diffRenamed: 'renamed', diffChanged: 'metadata changed', rollback: 'Rollback catalog', rollbackConfirm: 'Rollback this provider catalog to the selected snapshot?', credentials: 'Credential diagnostics', checkCredentials: 'Check credentials', missingCredential: 'not configured', credentialReady: 'configured', report: 'Run report', success: 'success', partial: 'partial success', failure: 'failed', empty: 'no configured providers', notifications: 'Notifications', markRead: 'Mark read', acknowledge: 'Acknowledge', noNotifications: 'No notifications.',
9
44
  }
10
45
  const ru = {
11
- title: 'Синхронизация моделей', desc: 'Обновляет каталоги моделей у провайдеров с API-ключом. По умолчанию только dry-run; применение запускается явно.', loading: 'Загрузка…', refresh: 'Обновить статус', refreshAll: 'Обновить все', run: 'Проверить', apply: 'Применить изменения', dryRun: 'Только просмотр (dry-run)', all: 'Все API-key провайдеры', ok: 'готов', dormant: 'не настроен', running: 'выполняется…', noProviders: 'API-key провайдеры не найдены.', lastRun: 'Последний запуск', selectModels: 'Выбрать модели', saveSelection: 'Сохранить и применить выбор', selectAtLeastOne: 'Выберите хотя бы одну модель или нажмите «Все модели».', cancel: 'Отмена', selected: 'выбрано', allModels: 'Все модели', noModels: 'В текущем каталоге нет моделей.', pickerNote: 'Выбор сразу применяется к пикеру моделей DSH.',
46
+ title: 'Синхронизация моделей', desc: 'Обновляет каталоги моделей у провайдеров с API-ключом. По умолчанию только dry-run; применение запускается явно.', loading: 'Загрузка…', refresh: 'Обновить статус', refreshAll: 'Обновить все', run: 'Проверить', apply: 'Применить изменения', dryRun: 'Только просмотр (dry-run)', all: 'Все API-key провайдеры', ok: 'готов', dormant: 'не настроен', running: 'выполняется…', noProviders: 'API-key провайдеры не найдены.', lastRun: 'Последний запуск', selectModels: 'Выбрать модели', saveSelection: 'Сохранить и применить выбор', selectAtLeastOne: 'Выберите хотя бы одну модель или нажмите «Все модели».', cancel: 'Отмена', selected: 'выбрано', allModels: 'Все модели', noModels: 'В текущем каталоге нет моделей.', pickerNote: 'Выбор сразу применяется к пикеру моделей DSH.', health: 'Проверить доступность', healthOk: 'доступен', healthError: 'недоступен', healthDormant: 'не настроен', policy: 'Политика моделей', savePolicy: 'Сохранить политику', policyInclude: 'Включать (по одному шаблону в строке)', policyExclude: 'Исключать (по одному шаблону в строке)', policyRequire: 'Обязательные возможности (через запятую)', policyDeny: 'Запрещённые возможности (через запятую)', policyNote: 'Шаблоны ищутся в id, названии и тегах модели. Нажмите «Применить», чтобы обновить пикер DSH.', scheduler: 'Планировщик', nextRun: 'следующий запуск', schedulerOff: 'выключен', schedulerOn: 'включён', removeMissing: 'Подтвердить удаление устаревших', stale: 'устарела', deprecated: 'deprecated', history: 'История синхронизаций', historyRun: 'Запуск', diffAdded: 'добавлено', diffRemoved: 'удалено', diffRenamed: 'переименовано', diffChanged: 'изменена метаинформация', rollback: 'Откатить каталог', rollbackConfirm: 'Откатить каталог этого провайдера к выбранному снимку?', credentials: 'Диагностика ключей', checkCredentials: 'Проверить ключи', missingCredential: 'не настроен', credentialReady: 'настроен', report: 'Итог запуска', success: 'успех', partial: 'частичный успех', failure: 'ошибка', empty: 'нет настроенных провайдеров', notifications: 'Уведомления', markRead: 'Прочитано', acknowledge: 'Подтвердить', noNotifications: 'Новых уведомлений нет.',
12
47
  }
13
48
  const API = '/dsh-model-sync'
14
49
  function useLocale(ctx) {
@@ -16,22 +51,60 @@ window.__ModuleLoader__.load({
16
51
  }
17
52
  function ModelSyncSection({ ctx }) {
18
53
  const locale = useLocale(ctx); const t = locale === 'ru' ? ru : en
19
- const [status, setStatus] = React.useState(null); const [provider, setProvider] = React.useState(''); const [dryRun, setDryRun] = React.useState(true); const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState('')
20
- const [selectionProvider, setSelectionProvider] = React.useState(''); const [selectionDraft, setSelectionDraft] = React.useState([])
54
+ const [status, setStatus] = React.useState(null); const [provider, setProvider] = React.useState(''); const [dryRun, setDryRun] = React.useState(true); const [removeMissing, setRemoveMissing] = React.useState(false); const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(''); const [credentialStatus, setCredentialStatus] = React.useState(null)
55
+ const [selectionProvider, setSelectionProvider] = React.useState(''); const [selectionDraft, setSelectionDraft] = React.useState([]); const [policyProvider, setPolicyProvider] = React.useState(''); const [policyDraft, setPolicyDraft] = React.useState({ include: '', exclude: '', requireCapabilities: '', denyCapabilities: '' })
21
56
  const load = React.useCallback(() => { setError(''); return fetch(`${API}/status`).then((r) => r.json()).then(setStatus).catch((e) => setError(String(e?.message ?? e))) }, [])
22
- React.useEffect(() => { load(); const timer = setInterval(load, 15000); return () => clearInterval(timer) }, [load])
23
- const execute = (targetProvider) => { setBusy(true); setError(''); fetch(`${API}/run`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider: targetProvider || undefined, dryRun }) }).then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? `HTTP ${r.status}`); return body }).then(setStatus).catch((e) => setError(String(e?.message ?? e))).finally(() => setBusy(false)) }
24
- const openSelection = (row) => { const available = row.availableModels ?? row.models ?? []; setSelectionProvider(row.provider); setSelectionDraft(row.selectedModels?.length ? row.selectedModels : available.map((model) => model.id)) }
57
+ const loadCredentials = React.useCallback(() => { setError(''); return fetch(`${API}/credentials`).then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? `HTTP ${r.status}`); return body }).then(setCredentialStatus).catch((e) => setError(String(e?.message ?? e))) }, [])
58
+ React.useEffect(() => { ensureStyles(); load(); const timer = setInterval(load, 15000); return () => clearInterval(timer) }, [load])
59
+ const execute = (targetProvider) => { if (!dryRun && removeMissing && !window.confirm(t.removeMissing)) return; setBusy(true); setError(''); fetch(`${API}/run`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider: targetProvider || undefined, dryRun, removeMissing }) }).then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? `HTTP ${r.status}`); return body }).then(() => load()).catch((e) => setError(String(e?.message ?? e))).finally(() => setBusy(false)) }
60
+ const checkHealth = (targetProvider) => { setBusy(true); setError(''); fetch(`${API}/health`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(targetProvider ? { provider: targetProvider } : {}) }).then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? `HTTP ${r.status}`); return body }).then(setStatus).catch((e) => setError(String(e?.message ?? e))).finally(() => setBusy(false)) }
61
+ const checkCredentials = (targetProvider) => { setBusy(true); setError(''); fetch(`${API}/credentials/check`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(targetProvider ? { provider: targetProvider } : {}) }).then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? `HTTP ${r.status}`); return body }).then((body) => { setCredentialStatus(body.credentials); setStatus((current) => ({ ...(current ?? {}), lastHealth: body.health })) }).catch((e) => setError(String(e?.message ?? e))).finally(() => setBusy(false)) }
62
+ const updateNotification = (id, action) => { setBusy(true); setError(''); fetch(API + '/notifications/' + action, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }) }).then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? ('HTTP ' + r.status)); return body }).then(() => load()).catch((e) => setError(String(e?.message ?? e))).finally(() => setBusy(false)) }
63
+ const openSelection = (row) => { const available = row.availableModels ?? row.models ?? []; const defaults = row.selectableModels ?? available; setSelectionProvider(row.provider); setSelectionDraft(row.selectedModels?.length ? row.selectedModels : defaults.map((model) => model.id)) }
25
64
  const closeSelection = () => { setSelectionProvider(''); setSelectionDraft([]) }
65
+ const openPolicy = (row) => { const policy = status?.policies?.[row.provider] ?? row.policy ?? {}; setPolicyProvider(row.provider); setPolicyDraft({ include: (policy.include ?? []).join('\n'), exclude: (policy.exclude ?? []).join('\n'), requireCapabilities: Object.keys(policy.requireCapabilities ?? {}).join(', '), denyCapabilities: Object.keys(policy.denyCapabilities ?? {}).join(', ') }) }
66
+ const closePolicy = () => { setPolicyProvider(''); setPolicyDraft({ include: '', exclude: '', requireCapabilities: '', denyCapabilities: '' }) }
67
+ const splitValues = (value) => [...new Set(String(value ?? '').split(/[,\n]+/).map((item) => item.trim()).filter(Boolean))]
68
+ const capabilityValues = (value) => Object.fromEntries(splitValues(value).map((key) => [key, true]))
69
+ const savePolicy = () => { setBusy(true); setError(''); fetch(`${API}/policy`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider: policyProvider, include: splitValues(policyDraft.include), exclude: splitValues(policyDraft.exclude), requireCapabilities: capabilityValues(policyDraft.requireCapabilities), denyCapabilities: capabilityValues(policyDraft.denyCapabilities) }) }).then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? `HTTP ${r.status}`); return body }).then(() => { closePolicy(); return load() }).catch((e) => setError(String(e?.message ?? e))).finally(() => setBusy(false)) }
26
70
  const saveSelection = () => { const row = rows.find((item) => item.provider === selectionProvider); const available = row?.availableModels ?? row?.models ?? []; if (available.length > 0 && selectionDraft.length === 0) { setError(t.selectAtLeastOne); return } const models = selectionDraft.length === available.length ? [] : selectionDraft; setBusy(true); setError(''); fetch(`${API}/selection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider: selectionProvider, models }) }).then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? `HTTP ${r.status}`); return body }).then((body) => { setStatus(body); closeSelection() }).catch((e) => setError(String(e?.message ?? e))).finally(() => setBusy(false)) }
27
- const rows = (status?.providers ?? []).filter((row) => row.configured); const selectedRow = rows.find((row) => row.provider === selectionProvider); const choices = selectedRow?.availableModels ?? selectedRow?.models ?? []; const button = (label, onClick) => React.createElement('button', { type: 'button', disabled: busy, onClick, style: { marginRight: 8 } }, label)
28
- return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 10, maxWidth: 680 } },
71
+ const rollback = (entry, targetProvider) => { if (!window.confirm(t.rollbackConfirm)) return; setBusy(true); setError(''); fetch(`${API}/history/rollback`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ historyId: entry.id, provider: targetProvider }) }).then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? `HTTP ${r.status}`); return body }).then(() => load()).catch((e) => setError(String(e?.message ?? e))).finally(() => setBusy(false)) }
72
+ const rows = (status?.providers ?? []).filter((row) => row.configured); const selectedRow = rows.find((row) => row.provider === selectionProvider); const choices = selectedRow?.availableModels ?? selectedRow?.models ?? []; const button = (label, onClick) => React.createElement('button', { type: 'button', disabled: busy, onClick, className: 'dms-btn' }, label)
73
+ const renderNotifications = () => {
74
+ const entries = status?.notifications ?? []
75
+ if (entries.length === 0) return React.createElement('small', { style: { display: 'block', marginTop: 6 } }, t.noNotifications)
76
+ return entries.map((item) => React.createElement('div', { key: item.id, style: { marginTop: 6 } },
77
+ React.createElement('span', null, item.title + ': ' + item.message + ' · ' + (item.occurrences ?? 1) + '×'),
78
+ item.readAt ? null : button(t.markRead, () => updateNotification(item.id, 'read')),
79
+ item.acknowledgedAt ? null : button(t.acknowledge, () => updateNotification(item.id, 'acknowledge')),
80
+ React.createElement('small', { style: { display: 'block', marginLeft: 12 } }, (item.providers ?? []).map((row) => row.provider + ': ' + row.status + (row.message ? ' · ' + row.message : '')).join('; '))))
81
+ }
82
+ const renderHistory = () => {
83
+ const entries = status?.history ?? []
84
+ if (entries.length === 0) return React.createElement('small', { style: { display: 'block', marginTop: 6 } }, t.noHistory)
85
+ return entries.map((entry) => React.createElement('details', { key: entry.id, style: { marginTop: 6 } },
86
+ React.createElement('summary', null, `${t.historyRun} #${entry.version} · ${new Date(entry.finishedAt).toLocaleString()}`),
87
+ (entry.providers ?? []).map((item) => {
88
+ const counts = item.counts ?? {}
89
+ const names = (items) => (items ?? []).map((model) => model?.after?.id ?? model?.id).filter(Boolean).join(', ')
90
+ const details = `+ ${names(item.diff?.added)}; - ${names(item.diff?.removed)}; ⇄ ${(item.diff?.renamed ?? []).map((pair) => `${pair.before?.id} → ${pair.after?.id}`).join(', ')}; ~ ${(item.diff?.changed ?? []).map((pair) => pair.after?.id).join(', ')}`
91
+ return React.createElement('div', { key: item.provider, style: { margin: '6px 0 6px 12px' } },
92
+ React.createElement('span', null, `${item.provider}: ${item.status} · +${counts.added ?? 0} ${t.diffAdded}, -${counts.removed ?? 0} ${t.diffRemoved}, ${counts.renamed ?? 0} ${t.diffRenamed}, ${counts.changed ?? 0} ${t.diffChanged}`),
93
+ item.hasRollback ? button(t.rollback, () => rollback(entry, item.provider)) : null,
94
+ React.createElement('small', { style: { display: 'block', marginTop: 4 } }, details))
95
+ })))
96
+ }
97
+ return React.createElement('div', { className: 'dms-wrap' },
29
98
  React.createElement('p', null, t.desc),
30
- React.createElement('div', null, React.createElement('select', { value: provider, onChange: (e) => setProvider(e.target.value) }, React.createElement('option', { value: '' }, t.all), rows.map((row) => React.createElement('option', { key: row.provider, value: row.provider }, row.provider))), React.createElement('label', { style: { marginLeft: 12 } }, React.createElement('input', { type: 'checkbox', checked: dryRun, onChange: (e) => setDryRun(e.target.checked) }), ` ${t.dryRun}`)),
31
- React.createElement('div', null, button(t.refresh, load), button(t.refreshAll, () => execute(undefined)), button(dryRun ? t.run : t.apply, () => execute(provider || undefined))),
32
- status?.lastRun ? React.createElement('small', null, `${t.lastRun}: ${new Date(status.lastRun.finishedAt).toLocaleString()}`) : null, status?.running || busy ? React.createElement('small', null, t.running) : null, error ? React.createElement('small', { style: { color: 'var(--dsw-alias-state-error-primary)' } }, error) : null, status === null ? React.createElement('small', null, t.loading) : null, rows.length === 0 && status ? React.createElement('small', null, t.noProviders) : null,
33
- rows.map((row) => React.createElement('div', { key: row.provider, style: { display: 'flex', alignItems: 'center', gap: 8 } }, React.createElement('span', null, `${row.provider}: ${row.configured ? t.ok : t.dormant} · ${row.models?.length ?? 0} models`), button(t.selectModels, () => openSelection(row)))),
34
- selectionProvider ? React.createElement('div', { style: { border: '1px solid var(--dsw-alias-border-primary)', padding: 12, borderRadius: 6 } }, React.createElement('strong', null, `${t.selectModels}: ${selectionProvider}`), React.createElement('div', { style: { margin: '8px 0' } }, choices.length === 0 ? React.createElement('small', null, t.noModels) : choices.map((model) => React.createElement('label', { key: model.id, style: { display: 'block', margin: '4px 0' } }, React.createElement('input', { type: 'checkbox', checked: selectionDraft.includes(model.id), onChange: (e) => setSelectionDraft((current) => e.target.checked ? [...current, model.id] : current.filter((id) => id !== model.id)) }), ` ${model.name ?? model.id} (${model.id})`))), React.createElement('small', null, t.pickerNote), React.createElement('div', { style: { marginTop: 8 } }, button(t.allModels, () => setSelectionDraft(choices.map((model) => model.id))), button(t.saveSelection, saveSelection), button(t.cancel, closeSelection))) : null,
99
+ React.createElement('div', { className: 'dms-toolbar' }, React.createElement('select', { value: provider, onChange: (e) => setProvider(e.target.value) }, React.createElement('option', { value: '' }, t.all), rows.map((row) => React.createElement('option', { key: row.provider, value: row.provider }, row.provider))), React.createElement('label', { style: { marginLeft: 12 } }, React.createElement('input', { type: 'checkbox', checked: dryRun, onChange: (e) => setDryRun(e.target.checked) }), ` ${t.dryRun}`), React.createElement('label', { style: { marginLeft: 12 } }, React.createElement('input', { type: 'checkbox', checked: removeMissing, onChange: (e) => setRemoveMissing(e.target.checked) }), ` ${t.removeMissing}`)),
100
+ React.createElement('div', { className: 'dms-actions' }, button(t.refresh, load), button(t.refreshAll, () => execute(undefined)), button(dryRun ? t.run : t.apply, () => execute(provider || undefined)), button(t.health, () => checkHealth(provider || undefined)), button(t.checkCredentials, () => checkCredentials(provider || undefined))),
101
+ status?.lastRun ? React.createElement('small', null, t.lastRun + ': ' + new Date(status.lastRun.finishedAt).toLocaleString()) : null, status?.lastReport ? React.createElement('div', { style: { marginTop: 8 } }, React.createElement('strong', null, t.report), React.createElement('small', { style: { display: 'block' } }, (t[status.lastReport.outcome] ?? status.lastReport.outcome) + ' · ' + (status.lastReport.counts?.succeeded ?? 0) + '/' + (status.lastReport.counts?.providers ?? 0) + ' providers · ' + (status.lastReport.counts?.models?.added ?? 0) + ' ' + t.diffAdded)) : null, status?.scheduler ? React.createElement('small', null, `${t.scheduler}: ${status.scheduler.active ? t.schedulerOn : t.schedulerOff}${status.scheduler.nextRunAt ? ` · ${t.nextRun}: ${new Date(status.scheduler.nextRunAt).toLocaleString()}` : ''}`) : null, status?.running || busy ? React.createElement('small', null, t.running) : null, error ? React.createElement('small', { style: { color: 'var(--dsw-alias-state-error-primary)' } }, error) : null, status === null ? React.createElement('small', null, t.loading) : null, rows.length === 0 && status ? React.createElement('small', null, t.noProviders) : null,
102
+ rows.map((row) => { const health = status?.lastHealth?.results?.find((item) => item.provider === row.provider); const healthText = health ? ` · ${health.status === 'ok' ? t.healthOk : health.status === 'dormant' ? t.healthDormant : t.healthError}` : ''; const lifecycleCounts = (row.lifecycleModels ?? []).reduce((counts, model) => { if (model.lifecycleStatus === 'stale') counts.stale += 1; if (model.lifecycleStatus === 'deprecated') counts.deprecated += 1; return counts }, { stale: 0, deprecated: 0 }); const lifecycleText = lifecycleCounts.stale || lifecycleCounts.deprecated ? ` · ${lifecycleCounts.stale} ${t.stale}, ${lifecycleCounts.deprecated} ${t.deprecated}` : ''; return React.createElement('div', { key: row.provider, className: 'dms-provider' }, React.createElement('span', null, `${row.provider}: ${row.configured ? t.ok : t.dormant} · ${row.models?.length ?? 0} models${lifecycleText}${healthText}`), button(t.selectModels, () => openSelection(row)), button(t.policy, () => openPolicy(row))) }),
103
+ React.createElement('div', { className: 'dms-section' }, React.createElement('strong', null, t.notifications), renderNotifications()),
104
+ React.createElement('div', { className: 'dms-section' }, React.createElement('strong', null, t.credentials), (credentialStatus?.results ?? []).length === 0 ? React.createElement('small', { style: { display: 'block', marginTop: 6 } }, t.noProviders) : (credentialStatus.results ?? []).map((item) => React.createElement('div', { key: item.provider, style: { marginTop: 6 } }, React.createElement('span', null, `${item.provider}: ${item.rotation?.configured ? `${item.rotation.keyCount} refs` : 'single ref'}${item.lastRequest ? ` · ${item.lastRequest.status}` : ''}`), (item.refs ?? []).map((ref) => React.createElement('small', { key: ref.ref || ref.label, style: { display: 'block', marginLeft: 12 } }, `${ref.order}. ${ref.label} · ${ref.configured === true ? t.credentialReady : t.missingCredential}${ref.source && ref.source !== 'unknown' ? ` · ${ref.source}` : ''}${ref.lastResolution?.status ? ` · ${ref.lastResolution.status}` : ''}`))))),
105
+ React.createElement('div', { className: 'dms-section' }, React.createElement('strong', null, t.history), renderHistory()),
106
+ selectionProvider ? React.createElement('div', { className: 'dms-editor' }, React.createElement('strong', null, `${t.selectModels}: ${selectionProvider}`), React.createElement('div', { style: { margin: '8px 0' } }, choices.length === 0 ? React.createElement('small', null, t.noModels) : choices.map((model) => React.createElement('label', { key: model.id, style: { display: 'block', margin: '4px 0' } }, React.createElement('input', { type: 'checkbox', checked: selectionDraft.includes(model.id), onChange: (e) => setSelectionDraft((current) => e.target.checked ? [...current, model.id] : current.filter((id) => id !== model.id)) }), ` ${model.name ?? model.id} (${model.id})`))), React.createElement('small', null, t.pickerNote), React.createElement('div', { style: { marginTop: 8 } }, button(t.allModels, () => setSelectionDraft(choices.map((model) => model.id))), button(t.saveSelection, saveSelection), button(t.cancel, closeSelection))) : null,
107
+ policyProvider ? React.createElement('div', { style: { border: '1px solid var(--dsw-alias-border-primary)', padding: 12, borderRadius: 6 } }, React.createElement('strong', null, `${t.policy}: ${policyProvider}`), React.createElement('label', { style: { display: 'block', marginTop: 8 } }, t.policyInclude, React.createElement('textarea', { rows: 3, value: policyDraft.include, onChange: (e) => setPolicyDraft((current) => ({ ...current, include: e.target.value })), style: { display: 'block', width: '100%' } })), React.createElement('label', { style: { display: 'block', marginTop: 8 } }, t.policyExclude, React.createElement('textarea', { rows: 3, value: policyDraft.exclude, onChange: (e) => setPolicyDraft((current) => ({ ...current, exclude: e.target.value })), style: { display: 'block', width: '100%' } })), React.createElement('label', { style: { display: 'block', marginTop: 8 } }, t.policyRequire, React.createElement('input', { value: policyDraft.requireCapabilities, onChange: (e) => setPolicyDraft((current) => ({ ...current, requireCapabilities: e.target.value })), style: { display: 'block', width: '100%' } })), React.createElement('label', { style: { display: 'block', marginTop: 8 } }, t.policyDeny, React.createElement('input', { value: policyDraft.denyCapabilities, onChange: (e) => setPolicyDraft((current) => ({ ...current, denyCapabilities: e.target.value })), style: { display: 'block', width: '100%' } })), React.createElement('small', null, t.policyNote), React.createElement('div', { style: { marginTop: 8 } }, button(t.savePolicy, savePolicy), button(t.cancel, closePolicy))) : null,
35
108
  )
36
109
  }
37
110
  function apply(ctx) { ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'dsh-model-sync', order: 25, label: () => 'Model Sync' }, (props) => React.createElement(ModelSyncSection, { ...props, ctx }))) }
@@ -1,4 +1,7 @@
1
1
  const PRINTABLE_API_KEY = /^[\x21-\x7E]+$/
2
+ const SAFE_SOURCE = /^(env|file|project-env|user-env|unknown)$/
3
+ const SAFE_REF = /^[A-Za-z_][A-Za-z0-9_]*$/
4
+ const MAX_DIAGNOSTIC_REFS = 32
2
5
 
3
6
  export function normalizeApiKey(value) {
4
7
  if (value === undefined || value === null) return ''
@@ -10,12 +13,90 @@ export function normalizeApiKey(value) {
10
13
  return key
11
14
  }
12
15
 
16
+ export function normalizeCredentialRef(value) {
17
+ if (typeof value !== 'string') return ''
18
+ const ref = value.trim()
19
+ return SAFE_REF.test(ref) ? ref : ''
20
+ }
21
+
22
+ export function maskCredentialRef(ref) {
23
+ const value = normalizeCredentialRef(ref)
24
+ if (!value) return 'invalid reference'
25
+ if (value.length <= 6) return `••••${value.slice(-2)}`
26
+ return `${value.slice(0, 2)}…${value.slice(-4)}`
27
+ }
28
+
29
+ function safeSource(value) {
30
+ const source = typeof value === 'string' ? value : 'unknown'
31
+ return SAFE_SOURCE.test(source) ? source : 'unknown'
32
+ }
33
+
34
+ function safeCode(error) {
35
+ const code = error?.code ?? error?.status ?? error?.statusCode
36
+ if (typeof code === 'number' && Number.isFinite(code)) return String(Math.trunc(code)).slice(0, 8)
37
+ if (typeof code === 'string' && /^[A-Za-z0-9_-]{1,32}$/.test(code)) return code
38
+ return 'credential-error'
39
+ }
40
+
41
+ function safeMessage(error) {
42
+ const source = error instanceof Error ? error.message : String(error ?? '')
43
+ return source.replace(/(api[_-]?key|token|secret|authorization|password)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]').slice(0, 160)
44
+ }
45
+
13
46
  export function createCredentialResolver(ctx) {
14
47
  const credentials = ctx?.get?.('credentials') ?? ctx?.credentials
15
- return async (ref) => {
16
- if (!ref) return ''
17
- if (!credentials?.resolve) throw new Error('DSH credentials service is unavailable')
18
- const result = await credentials.resolve(ref)
19
- return normalizeApiKey(typeof result === 'string' ? result : result?.value)
48
+ const observations = new Map()
49
+ const remember = (ref, patch) => {
50
+ const normalized = normalizeCredentialRef(ref)
51
+ if (!normalized) return
52
+ observations.set(normalized, { ...(observations.get(normalized) ?? {}), ...patch, at: Date.now() })
53
+ }
54
+ const resolver = async (ref) => {
55
+ const normalized = normalizeCredentialRef(ref)
56
+ if (!normalized) return ''
57
+ if (!credentials?.resolve) {
58
+ remember(normalized, { status: 'unavailable', code: 'CREDENTIALS_UNAVAILABLE' })
59
+ throw new Error('DSH credentials service is unavailable')
60
+ }
61
+ try {
62
+ const result = await credentials.resolve(normalized)
63
+ const value = normalizeApiKey(typeof result === 'string' ? result : result?.value)
64
+ remember(normalized, { status: value ? 'resolved' : 'missing', source: safeSource(result?.source) })
65
+ return value
66
+ } catch (error) {
67
+ const safe = Object.assign(new Error(safeMessage(error)), {
68
+ ...(error?.code === undefined ? {} : { code: error.code }),
69
+ ...(error?.status === undefined ? {} : { status: error.status }),
70
+ ...(error?.statusCode === undefined ? {} : { statusCode: error.statusCode }),
71
+ })
72
+ remember(normalized, { status: 'error', code: safeCode(error), message: safe.message })
73
+ throw safe
74
+ }
75
+ }
76
+ resolver.describe = async (ref) => {
77
+ const normalized = normalizeCredentialRef(ref)
78
+ if (!normalized) return { ref: '', label: maskCredentialRef(''), configured: false, status: 'invalid' }
79
+ const observed = observations.get(normalized)
80
+ if (!credentials?.describe) {
81
+ return { ref: normalized, label: maskCredentialRef(normalized), configured: 'unknown', status: 'describe-unavailable', ...(observed ? { lastResolution: structuredClone(observed) } : {}) }
82
+ }
83
+ try {
84
+ const info = await credentials.describe(normalized)
85
+ return {
86
+ ref: normalized,
87
+ label: maskCredentialRef(normalized),
88
+ configured: info?.configured === true,
89
+ source: safeSource(info?.source),
90
+ writable: info?.writable !== false,
91
+ ...(observed ? { lastResolution: structuredClone(observed) } : {}),
92
+ }
93
+ } catch (error) {
94
+ return { ref: normalized, label: maskCredentialRef(normalized), configured: 'unknown', status: 'describe-error', code: safeCode(error), message: safeMessage(error), ...(observed ? { lastResolution: structuredClone(observed) } : {}) }
95
+ }
96
+ }
97
+ resolver.diagnostics = async (refs) => {
98
+ const unique = [...new Set((Array.isArray(refs) ? refs : []).map(normalizeCredentialRef).filter(Boolean))].slice(0, MAX_DIAGNOSTIC_REFS)
99
+ return Promise.all(unique.map((ref, index) => resolver.describe(ref).then((row) => ({ order: index + 1, ...row }))))
20
100
  }
101
+ return resolver
21
102
  }
@@ -1,3 +1,4 @@
1
+ import { catalogRequestError } from './reliability.js'
1
2
  import { normalizeModels } from './models.js'
2
3
 
3
4
  const MAX_RESPONSE_BYTES = 4 * 1024 * 1024
@@ -30,6 +31,19 @@ export function canUseGenericAdapter(profile) {
30
31
  return Boolean(profile?.baseURL) && SUPPORTED_APIS.has(profile?.api || 'openai-completions')
31
32
  }
32
33
 
34
+ function requestHeaders(apiKey) {
35
+ const headers = { Accept: 'application/json' }
36
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`
37
+ return headers
38
+ }
39
+
40
+ async function resolveGenericRequest(profile, { resolveCredential, fetchImpl, signal }) {
41
+ const apiKey = profile.apiKeyEnv ? await resolveCredential(profile.apiKeyEnv) : ''
42
+ const response = await fetchImpl(endpointFor(profile.baseURL), { headers: requestHeaders(apiKey), signal })
43
+ if (!response.ok) throw catalogRequestError(response.status)
44
+ return response
45
+ }
46
+
33
47
  export async function discoverOpenAIModels(profile, {
34
48
  resolveCredential,
35
49
  fetchImpl = fetch,
@@ -38,11 +52,7 @@ export async function discoverOpenAIModels(profile, {
38
52
  if (!canUseGenericAdapter(profile)) {
39
53
  throw new Error('generic discovery requires an OpenAI-compatible baseURL and protocol')
40
54
  }
41
- const apiKey = profile.apiKeyEnv ? await resolveCredential(profile.apiKeyEnv) : ''
42
- const headers = { Accept: 'application/json' }
43
- if (apiKey) headers.Authorization = `Bearer ${apiKey}`
44
- const response = await fetchImpl(endpointFor(profile.baseURL), { headers, signal })
45
- if (!response.ok) throw new Error(`model catalog request failed with HTTP ${response.status}`)
55
+ const response = await resolveGenericRequest(profile, { resolveCredential, fetchImpl, signal })
46
56
  let payload
47
57
  try {
48
58
  payload = JSON.parse(await readBody(response))
@@ -52,6 +62,21 @@ export async function discoverOpenAIModels(profile, {
52
62
  return normalizeModels(profile.provider ?? '', rowsFrom(payload))
53
63
  }
54
64
 
65
+
66
+ export async function probeOpenAIModels(profile, {
67
+ resolveCredential,
68
+ fetchImpl = fetch,
69
+ signal,
70
+ } = {}) {
71
+ if (!canUseGenericAdapter(profile)) {
72
+ throw new Error('generic health probe requires an OpenAI-compatible baseURL and protocol')
73
+ }
74
+ const startedAt = Date.now()
75
+ const response = await resolveGenericRequest(profile, { resolveCredential, fetchImpl, signal })
76
+ if (typeof response.body?.cancel === 'function') await response.body.cancel()
77
+ return { statusCode: response.status, latencyMs: Date.now() - startedAt }
78
+ }
79
+
55
80
  export function createGenericAdapter({ resolveCredential, fetchImpl = fetch } = {}) {
56
81
  return Object.freeze({
57
82
  id: 'openai-compatible',
@@ -63,5 +88,12 @@ export function createGenericAdapter({ resolveCredential, fetchImpl = fetch } =
63
88
  ...options,
64
89
  })
65
90
  },
91
+ health(profile, options = {}) {
92
+ return probeOpenAIModels(profile, {
93
+ resolveCredential,
94
+ fetchImpl,
95
+ ...options,
96
+ })
97
+ },
66
98
  })
67
99
  }