@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 +27 -0
- package/lib/adapter-registry.js +147 -27
- package/lib/client.js +87 -14
- package/lib/credentials.js +86 -5
- package/lib/generic-adapter.js +37 -5
- package/lib/history.js +138 -0
- package/lib/http.js +121 -4
- package/lib/index.js +62 -1
- package/lib/lifecycle.js +130 -0
- package/lib/models.js +203 -11
- package/lib/policy.js +71 -0
- package/lib/reconcile.js +8 -2
- package/lib/reliability.js +112 -0
- package/lib/reporting.js +155 -0
- package/lib/scheduler.js +127 -21
- package/lib/synchronizer.js +427 -36
- package/package.json +1 -1
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.
|
package/lib/adapter-registry.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
34
|
-
if (
|
|
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
|
|
121
|
+
async function requestSpecific(profile, descriptor, { resolveCredential, fetchImpl, signal }) {
|
|
49
122
|
const apiKey = profile.apiKeyEnv ? await resolveCredential(profile.apiKeyEnv) : ''
|
|
50
|
-
const url = new URL(
|
|
123
|
+
const url = new URL(descriptor.endpoint)
|
|
51
124
|
const headers = { Accept: 'application/json', ...(descriptor.headers ?? {}) }
|
|
52
|
-
if (descriptor.auth === 'bearer' && apiKey) headers.Authorization =
|
|
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
|
-
|
|
56
|
-
|
|
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)
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
throw
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
199
|
+
id: 'provider:' + profile.provider,
|
|
78
200
|
canHandle: () => true,
|
|
79
|
-
discover: (current, options = {}) => fetchSpecific(current, descriptor, {
|
|
80
|
-
|
|
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.
|
|
23
|
-
|
|
24
|
-
const
|
|
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
|
|
28
|
-
|
|
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',
|
|
31
|
-
React.createElement('div',
|
|
32
|
-
status?.lastRun ? React.createElement('small', null, `${t.
|
|
33
|
-
rows.map((row) =>
|
|
34
|
-
|
|
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 }))) }
|
package/lib/credentials.js
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
}
|
package/lib/generic-adapter.js
CHANGED
|
@@ -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
|
|
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
|
}
|