@thebtf/dsh-cliproxyapi-provider 0.0.0-stage → 0.4.3
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 +39 -3
- package/README_EN.md +39 -0
- package/client.js +517 -0
- package/cordis.patch.yml +5 -0
- package/details/en.md +9 -0
- package/details/ru.md +9 -0
- package/details/zh.md +9 -0
- package/locale/en.json +7 -0
- package/locale/ru.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +85 -6
- package/src/adapter.js +68 -0
- package/src/catalog-store.js +62 -0
- package/src/catalog.js +129 -0
- package/src/connection-store.js +98 -0
- package/src/connection.js +114 -0
- package/src/gateway.js +411 -0
- package/src/index.js +513 -0
- package/src/migration.js +132 -0
- package/src/policy.js +42 -0
- package/src/secrets.js +1 -0
- package/src/transport.js +114 -0
package/src/gateway.js
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { ConnectionError, resolveIdentity, saveConnection, validateEndpoint } from './connection.js'
|
|
3
|
+
import { readCatalogCache, writeCatalogCache } from './catalog-store.js'
|
|
4
|
+
import { legacyView, prepareLegacy, cutoverLegacy, finishLegacy, rollbackLegacy, discardPrepared } from './migration.js'
|
|
5
|
+
import { createGatewayAdapter } from './adapter.js'
|
|
6
|
+
import { validatePolicy } from './policy.js'
|
|
7
|
+
import { credentialField } from './secrets.js'
|
|
8
|
+
import { readConnectionSnapshot, removeConnectionSnapshot, writeConnectionSnapshot } from './connection-store.js'
|
|
9
|
+
|
|
10
|
+
const NS = 'llm-cliproxyapi'
|
|
11
|
+
const PROVIDER = 'CLIProxyAPI'
|
|
12
|
+
const safeObject = (value) => {
|
|
13
|
+
if (Array.isArray(value)) return value.map(safeObject)
|
|
14
|
+
if (!value || typeof value !== 'object') return value
|
|
15
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, credentialField(key) ? '[redacted]' : safeObject(item)]))
|
|
16
|
+
}
|
|
17
|
+
const errorView = (code, message) => ({ code, message })
|
|
18
|
+
const generationFor = (id, revision) => createHash('sha256').update(id).update('\0').update(String(revision)).digest('hex')
|
|
19
|
+
const fields = {
|
|
20
|
+
'preview-models': ['type', 'baseURL', 'apiKey', 'allowEndpointReuse'],
|
|
21
|
+
'refresh-models': ['type', 'connectionGeneration'],
|
|
22
|
+
'save-connection': ['type', 'expectedRevision', 'operationId', 'baseURL', 'authEdit', 'policy', 'selectionChanges'],
|
|
23
|
+
'save-selection': ['type', 'expectedRevision', 'operationId', 'selectionChanges'],
|
|
24
|
+
'set-enabled': ['type', 'expectedRevision', 'operationId', 'enabled'],
|
|
25
|
+
'forget-key': ['type', 'expectedRevision', 'operationId'],
|
|
26
|
+
'import-legacy': ['type', 'expectedRevision', 'operationId', 'expectedLegacyRevision', 'fingerprint'],
|
|
27
|
+
'restore-connection': ['type', 'expectedRevision', 'operationId'],
|
|
28
|
+
'rollback-legacy': ['type', 'expectedRevision', 'operationId'],
|
|
29
|
+
}
|
|
30
|
+
const objectWithFields = (input, allowed) => {
|
|
31
|
+
if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some((key) => !allowed.includes(key))) throw new ConnectionError('INVALID_REQUEST', 'Invalid request fields')
|
|
32
|
+
}
|
|
33
|
+
const validatePreview = (input, allowed) => {
|
|
34
|
+
objectWithFields(input, allowed)
|
|
35
|
+
if (input.apiKey !== undefined && (typeof input.apiKey !== 'string' || Buffer.byteLength(input.apiKey) > 4096)) throw new ConnectionError('INVALID_AUTH', 'Invalid key')
|
|
36
|
+
if (input.allowEndpointReuse !== undefined && typeof input.allowEndpointReuse !== 'boolean') throw new ConnectionError('INVALID_REQUEST', 'Invalid endpoint reuse')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function installGateway(ctx, config, discover, readJson, previewKey) {
|
|
40
|
+
const diagnostics = []
|
|
41
|
+
let registration
|
|
42
|
+
let controller
|
|
43
|
+
let catalog
|
|
44
|
+
let freshness = 'empty'
|
|
45
|
+
let catalogError
|
|
46
|
+
let activationError
|
|
47
|
+
// Recovery diagnostics describe this process start, not the saved connection,
|
|
48
|
+
// so they are never persisted into the state the UI reads back.
|
|
49
|
+
let recoveryError
|
|
50
|
+
let loading = false
|
|
51
|
+
let stopped = false
|
|
52
|
+
let wakeDispose
|
|
53
|
+
const activeRefreshes = new Set()
|
|
54
|
+
const abortRefreshes = () => { for (const active of activeRefreshes) active.abort() }
|
|
55
|
+
let failures = 0
|
|
56
|
+
const cancelWake = () => { wakeDispose?.(); wakeDispose = undefined }
|
|
57
|
+
const schedule = (delay, generation) => {
|
|
58
|
+
cancelWake()
|
|
59
|
+
if (stopped || !generation || delay <= 0) return
|
|
60
|
+
wakeDispose = ctx.timeout(() => {
|
|
61
|
+
wakeDispose = undefined
|
|
62
|
+
const value = current()
|
|
63
|
+
if (stopped || value.generation !== generation || value.enabled === false || !value.baseURL || value.authIdentity?.type === 'missing' || ['prepared', 'rollback'].includes(value.migration?.phase)) return
|
|
64
|
+
controller = new AbortController()
|
|
65
|
+
void refresh(generation, controller.signal).catch(() => { })
|
|
66
|
+
}, delay)
|
|
67
|
+
}
|
|
68
|
+
const descriptor = () => {
|
|
69
|
+
const result = ctx.settings.describe().find((row) => row.ns === NS)
|
|
70
|
+
if (!result || !Number.isSafeInteger(result.revision)) throw new ConnectionError('SETTINGS_UNAVAILABLE', 'Connection settings are unavailable')
|
|
71
|
+
return result
|
|
72
|
+
}
|
|
73
|
+
const current = () => descriptor().value ?? {}
|
|
74
|
+
const identityKey = async (identity) => {
|
|
75
|
+
if (identity?.type === 'missing') return undefined
|
|
76
|
+
return resolveIdentity(ctx.credentials, identity)
|
|
77
|
+
}
|
|
78
|
+
const gateway = createGatewayAdapter(ctx, undefined, diagnostics, () => { void activate().catch(() => { }) })
|
|
79
|
+
const compatibleCatalog = (value) => catalog && catalog.generation === value.generation && catalog.baseURL === value.baseURL && JSON.stringify(catalog.authIdentity) === JSON.stringify(value.authIdentity)
|
|
80
|
+
// The settings row lives in the profile patch file. It can disappear when the
|
|
81
|
+
// profile is rebuilt, a compatibility gate denies a row, or the bundle is
|
|
82
|
+
// reinstalled, so every saved connection is mirrored into a plugin-owned
|
|
83
|
+
// snapshot under the DSH home and can be restored without the user retyping it.
|
|
84
|
+
let durable
|
|
85
|
+
let durableLoaded = false
|
|
86
|
+
const loadDurable = async () => {
|
|
87
|
+
if (durableLoaded) return durable
|
|
88
|
+
durableLoaded = true
|
|
89
|
+
try { durable = await readConnectionSnapshot(ctx.settings.documentPath) } catch { durable = undefined }
|
|
90
|
+
return durable
|
|
91
|
+
}
|
|
92
|
+
const persist = async (value) => {
|
|
93
|
+
if (!value?.baseURL || !value?.generation) return
|
|
94
|
+
try { durable = await writeConnectionSnapshot(ctx.settings.documentPath, value); durableLoaded = true } catch { }
|
|
95
|
+
}
|
|
96
|
+
const durability = async () => {
|
|
97
|
+
const snapshot = await loadDurable()
|
|
98
|
+
return snapshot ? { available: true, savedAt: snapshot.savedAt, baseURL: snapshot.baseURL } : { available: false }
|
|
99
|
+
}
|
|
100
|
+
const restore = async (operationId, expectedRevision) => {
|
|
101
|
+
const snapshot = await loadDurable()
|
|
102
|
+
if (!snapshot) throw new ConnectionError('NO_SNAPSHOT', 'No saved connection is available to restore')
|
|
103
|
+
const before = descriptor()
|
|
104
|
+
if (before.value?.baseURL) throw new ConnectionError('SETTINGS_CONFLICT', 'A connection is already configured')
|
|
105
|
+
if (typeof operationId !== 'string' || !operationId || operationId.length > 256) throw new ConnectionError('INVALID_REQUEST', 'An operation ID is required')
|
|
106
|
+
if (Number.isSafeInteger(expectedRevision) && expectedRevision !== before.revision) throw new ConnectionError('SETTINGS_CONFLICT', 'Connection settings have changed')
|
|
107
|
+
const generation = generationFor(operationId, before.revision)
|
|
108
|
+
const values = {
|
|
109
|
+
baseURL: snapshot.baseURL,
|
|
110
|
+
authIdentity: structuredClone(snapshot.authIdentity),
|
|
111
|
+
enabled: snapshot.enabled !== false,
|
|
112
|
+
policy: structuredClone(snapshot.policy ?? {}),
|
|
113
|
+
modelSelection: structuredClone(snapshot.modelSelection ?? {}),
|
|
114
|
+
overrides: structuredClone(snapshot.overrides ?? {}),
|
|
115
|
+
operationId,
|
|
116
|
+
generation,
|
|
117
|
+
}
|
|
118
|
+
const ops = Object.entries(values).map(([key, value]) => ({ op: 'set', path: [key], value }))
|
|
119
|
+
await ctx.settings.mutate(NS, ops, before.revision)
|
|
120
|
+
return generation
|
|
121
|
+
}
|
|
122
|
+
const activate = async () => {
|
|
123
|
+
const value = current()
|
|
124
|
+
let ready = !!value.baseURL && value.enabled !== false && !['prepared', 'rollback'].includes(value.migration?.phase) && compatibleCatalog(value)
|
|
125
|
+
if (ready && value.authIdentity?.type !== 'none') {
|
|
126
|
+
try { ready = !!(await identityKey(value.authIdentity)) } catch { ready = false }
|
|
127
|
+
}
|
|
128
|
+
ready = ready && catalog.effective.some((model) => value.modelSelection?.[model.id] !== false)
|
|
129
|
+
if (stopped || current().generation !== value.generation) return
|
|
130
|
+
if (!ready) {
|
|
131
|
+
try { registration?.replace([]) } catch { activationError = errorView('ACTIVATION_FAILED', 'Could not deactivate CPA route') }
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
gateway.update({ ...value, models: catalog.effective })
|
|
136
|
+
if (registration) registration.replace([PROVIDER])
|
|
137
|
+
else registration = ctx.llm.registerAdapter([PROVIDER], gateway.adapter)
|
|
138
|
+
activationError = undefined
|
|
139
|
+
recoveryError = undefined
|
|
140
|
+
void persist(value)
|
|
141
|
+
} catch {
|
|
142
|
+
try { registration?.replace([]) } catch { }
|
|
143
|
+
activationError = errorView('ACTIVATION_FAILED', 'Could not activate CPA route; another owner may be using it')
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const view = async () => {
|
|
147
|
+
const { value = {}, revision } = descriptor()
|
|
148
|
+
let keyConfigured = false
|
|
149
|
+
if (value.authIdentity && value.authIdentity.type !== 'none') {
|
|
150
|
+
try { keyConfigured = !!(await identityKey(value.authIdentity)) } catch { keyConfigured = false }
|
|
151
|
+
}
|
|
152
|
+
const authReady = value.authIdentity?.type === 'none' || keyConfigured
|
|
153
|
+
const models = compatibleCatalog(value) ? catalog.effective : []
|
|
154
|
+
const configured = !!value.baseURL
|
|
155
|
+
const saved = await durability()
|
|
156
|
+
const status = !configured ? 'not-configured' : value.enabled === false ? 'disabled' : !authReady ? 'missing-credential' : loading ? 'catalog-loading' : activationError ? 'error' : !models.length ? (catalogError ? 'error' : 'catalog-loading') : !models.some((m) => value.modelSelection?.[m.id] !== false) ? 'no-models' : freshness === 'stale' ? 'stale' : 'ready'
|
|
157
|
+
return {
|
|
158
|
+
revision, ...(value.generation ? { generation: value.generation } : {}), ...(value.operationId ? { operationId: value.operationId } : {}),
|
|
159
|
+
configured, enabled: value.enabled !== false, baseURL: value.baseURL ?? null,
|
|
160
|
+
authMode: value.authIdentity?.type === 'missing' ? 'record' : value.authIdentity?.type ?? null,
|
|
161
|
+
keyConfigured, keyWritable: ctx.settings.writable !== false,
|
|
162
|
+
modelSelection: value.modelSelection ?? {}, policy: safeObject(value.policy ?? {}), models,
|
|
163
|
+
status, catalog: { freshness: loading ? 'loading' : freshness, ...(compatibleCatalog(value) ? { fetchedAt: catalog.fetchedAt } : {}), ...(catalogError ? { error: catalogError } : {}) },
|
|
164
|
+
diagnostics: [...diagnostics], ...(activationError ? { activationError } : {}), ...(recoveryError ? { recoveryError } : {}),
|
|
165
|
+
durable: saved, legacy: legacyView(ctx.settings),
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const load = async () => {
|
|
169
|
+
const value = current()
|
|
170
|
+
if (!value.baseURL || !value.generation) return
|
|
171
|
+
try {
|
|
172
|
+
const saved = await readCatalogCache(ctx.settings.documentPath, value.generation, value.baseURL, value.authIdentity)
|
|
173
|
+
if (saved && current().generation === value.generation) { catalog = saved; freshness = 'stale' }
|
|
174
|
+
} catch { catalogError = errorView('CACHE_UNAVAILABLE', 'Catalog cache could not be read') }
|
|
175
|
+
await activate()
|
|
176
|
+
}
|
|
177
|
+
const refresh = async (generation, signal) => {
|
|
178
|
+
if (stopped) throw new ConnectionError('STALE_GENERATION', 'Connection is no longer active')
|
|
179
|
+
const value = structuredClone(current())
|
|
180
|
+
if (!value.baseURL || generation !== value.generation) throw new ConnectionError('STALE_GENERATION', 'Connection has changed')
|
|
181
|
+
if (value.enabled === false || value.authIdentity?.type === 'missing') throw new ConnectionError('MISSING_CREDENTIAL', 'CPA connection is disabled or missing a credential')
|
|
182
|
+
const active = new AbortController()
|
|
183
|
+
const forwardAbort = () => active.abort(signal.reason)
|
|
184
|
+
if (signal?.aborted) forwardAbort()
|
|
185
|
+
else signal?.addEventListener('abort', forwardAbort, { once: true })
|
|
186
|
+
activeRefreshes.add(active)
|
|
187
|
+
loading = true
|
|
188
|
+
try {
|
|
189
|
+
const key = await identityKey(value.authIdentity)
|
|
190
|
+
if (value.authIdentity?.type !== 'none' && !key) throw new ConnectionError('MISSING_CREDENTIAL', 'CPA credential is missing')
|
|
191
|
+
if (active.signal.aborted) throw active.signal.reason
|
|
192
|
+
const result = await discover(value, active.signal, key)
|
|
193
|
+
if (active.signal.aborted) throw active.signal.reason
|
|
194
|
+
if (current().generation !== generation) throw new ConnectionError('STALE_GENERATION', 'Connection has changed')
|
|
195
|
+
const next = { generation, baseURL: value.baseURL, authIdentity: value.authIdentity, fetchedAt: Date.now(), raw: result.discovery.map((m) => m.catalogMetadata), effective: result.discovery }
|
|
196
|
+
let persistenceError
|
|
197
|
+
try { await writeCatalogCache(ctx.settings.documentPath, next) } catch { persistenceError = errorView('CACHE_UNAVAILABLE', 'Catalog cache could not be saved') }
|
|
198
|
+
if (active.signal.aborted) throw active.signal.reason
|
|
199
|
+
if (current().generation !== generation) throw new ConnectionError('STALE_GENERATION', 'Connection has changed')
|
|
200
|
+
catalog = next
|
|
201
|
+
freshness = 'fresh'
|
|
202
|
+
catalogError = persistenceError
|
|
203
|
+
failures = 0
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (!stopped && current().generation === generation && !active.signal.aborted) {
|
|
206
|
+
if (compatibleCatalog(value)) freshness = 'stale'
|
|
207
|
+
catalogError = { ...errorView(error?.code ?? 'CATALOG_TRANSPORT', 'Could not refresh the CPA catalog'), ...(Number.isInteger(error?.status) ? { status: error.status } : {}) }
|
|
208
|
+
if (error?.code !== 'MISSING_CREDENTIAL') failures++
|
|
209
|
+
}
|
|
210
|
+
throw error
|
|
211
|
+
} finally {
|
|
212
|
+
activeRefreshes.delete(active)
|
|
213
|
+
signal?.removeEventListener('abort', forwardAbort)
|
|
214
|
+
loading = false
|
|
215
|
+
await activate()
|
|
216
|
+
if (!stopped && current().generation === generation && current().migration?.phase === 'cutover' && registration && !activationError) await finishLegacy(ctx.settings)
|
|
217
|
+
if (!stopped && current().generation === generation && current().enabled !== false && current().authIdentity?.type !== 'missing' && !active.signal.aborted) {
|
|
218
|
+
const cfg = config()
|
|
219
|
+
schedule(failures ? Math.min(cfg.retryInitialMs * 2 ** Math.max(0, failures - 1), cfg.retryMaxMs) : cfg.refreshIntervalMs, generation)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const mutate = async (input, values) => {
|
|
224
|
+
const before = descriptor()
|
|
225
|
+
if (!Number.isSafeInteger(input.expectedRevision) || typeof input.operationId !== 'string' || !input.operationId || input.operationId.length > 256) throw new ConnectionError('INVALID_REQUEST', 'Revision and operation ID are required')
|
|
226
|
+
if (before.value?.operationId === input.operationId) {
|
|
227
|
+
if (Object.entries(values).some(([key, value]) => JSON.stringify(before.value?.[key]) !== JSON.stringify(value))) throw new ConnectionError('OPERATION_CONFLICT', 'Operation ID belongs to another operation')
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
if (before.revision !== input.expectedRevision) throw new ConnectionError('SETTINGS_CONFLICT', 'Connection settings have changed')
|
|
231
|
+
const generation = generationFor(input.operationId, before.revision)
|
|
232
|
+
const ops = Object.entries({ ...values, operationId: input.operationId, generation }).map(([key, value]) => ({ op: 'set', path: [key], value }))
|
|
233
|
+
try { await ctx.settings.mutate(NS, ops, before.revision) } catch (error) {
|
|
234
|
+
if (error?.code === 'SETTINGS_CONFLICT') throw error
|
|
235
|
+
if (descriptor().value?.operationId !== input.operationId) throw new ConnectionError('UNKNOWN', 'Save outcome is unknown; read state before retrying')
|
|
236
|
+
}
|
|
237
|
+
if (descriptor().value?.operationId !== input.operationId) throw new ConnectionError('UNKNOWN', 'Save outcome is unknown; read state before retrying')
|
|
238
|
+
cancelWake(); abortRefreshes()
|
|
239
|
+
if (catalog && catalog.generation === before.value?.generation) {
|
|
240
|
+
catalog = { ...catalog, generation }
|
|
241
|
+
try { await writeCatalogCache(ctx.settings.documentPath, catalog) } catch { freshness = 'stale'; catalogError = errorView('CACHE_UNAVAILABLE', 'Catalog cache could not be saved') }
|
|
242
|
+
}
|
|
243
|
+
await activate()
|
|
244
|
+
await persist(current())
|
|
245
|
+
const value = current()
|
|
246
|
+
if (value.generation === generation && value.enabled !== false && value.authIdentity?.type !== 'missing') {
|
|
247
|
+
const cfg = config()
|
|
248
|
+
schedule(failures ? Math.min(cfg.retryInitialMs * 2 ** Math.max(0, failures - 1), cfg.retryMaxMs) : cfg.refreshIntervalMs, generation)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const preview = async (input, signal) => {
|
|
252
|
+
const endpoint = validateEndpoint(input.baseURL)
|
|
253
|
+
const key = await previewKey(endpoint, input.apiKey, input.allowEndpointReuse)
|
|
254
|
+
const result = await discover({ baseURL: endpoint }, signal, key)
|
|
255
|
+
return result.discovery
|
|
256
|
+
}
|
|
257
|
+
const command = async (input, signal) => {
|
|
258
|
+
objectWithFields(input, fields[input?.type] ?? [])
|
|
259
|
+
if (input.type === 'preview-models') { validatePreview(input, fields[input.type]); return preview(input, signal) }
|
|
260
|
+
if (input.type === 'save-connection') {
|
|
261
|
+
const authFields = { keep: ['type', 'allowEndpointReuse'], replace: ['type', 'key'], reference: ['type', 'ref', 'allowEndpointReuse'], none: ['type'] }
|
|
262
|
+
objectWithFields(input.authEdit, authFields[input.authEdit?.type] ?? [])
|
|
263
|
+
if (input.authEdit?.allowEndpointReuse !== undefined && typeof input.authEdit.allowEndpointReuse !== 'boolean') throw new ConnectionError('INVALID_AUTH', 'Invalid endpoint reuse')
|
|
264
|
+
if (input.authEdit?.type === 'replace' && (typeof input.authEdit.key !== 'string' || Buffer.byteLength(input.authEdit.key) > 4096)) throw new ConnectionError('INVALID_AUTH', 'Invalid key')
|
|
265
|
+
}
|
|
266
|
+
if (input.type === 'refresh-models') { await refresh(input.connectionGeneration, signal); return view() }
|
|
267
|
+
if (input.type === 'save-connection') {
|
|
268
|
+
validatePolicy(input.policy === undefined ? current().policy ?? {} : input.policy)
|
|
269
|
+
// A legacy llm-pi-ai provider owns the same route. Adopt it instead of
|
|
270
|
+
// refusing the save: prepareLegacy records a full backup, so the cutover
|
|
271
|
+
// stays reversible through rollback-legacy. The cutover runs only after the
|
|
272
|
+
// save itself succeeded, so a rejected draft leaves the legacy route intact.
|
|
273
|
+
let save = input
|
|
274
|
+
let adopted = false
|
|
275
|
+
if (!current().baseURL) {
|
|
276
|
+
const summary = legacyView(ctx.settings)
|
|
277
|
+
if (summary.available) {
|
|
278
|
+
await prepareLegacy(ctx.settings, { expectedRevision: descriptor().revision, expectedLegacyRevision: summary.revision, fingerprint: summary.fingerprint, operationId: input.operationId + ':adopt' })
|
|
279
|
+
save = { ...input, expectedRevision: descriptor().revision }
|
|
280
|
+
adopted = true
|
|
281
|
+
} else if (ctx.settings.describe().find((row) => row.ns === 'llm-pi-ai')?.value?.providers?.[PROVIDER]) {
|
|
282
|
+
throw new ConnectionError('LEGACY_CONFLICT', summary.reason ?? 'The existing CPA provider must be resolved first')
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const previous = current()
|
|
286
|
+
try {
|
|
287
|
+
await saveConnection(ctx.settings, ctx.credentials, save)
|
|
288
|
+
} catch (error) {
|
|
289
|
+
// The prepared migration already wrote our row. A definite rejection is
|
|
290
|
+
// undone so the legacy route keeps working; an ambiguous outcome is left
|
|
291
|
+
// for rollback-legacy, because discarding could erase a real save.
|
|
292
|
+
if (adopted && error?.code !== 'UNKNOWN') { try { await discardPrepared(ctx.settings) } catch { } }
|
|
293
|
+
throw error
|
|
294
|
+
}
|
|
295
|
+
if (adopted) await cutoverLegacy(ctx.settings)
|
|
296
|
+
const value = current()
|
|
297
|
+
if (previous.generation !== value.generation) { cancelWake(); abortRefreshes(); failures = 0; catalog = undefined; freshness = 'empty'; catalogError = undefined }
|
|
298
|
+
try { await refresh(value.generation, signal) } catch { await activate() }
|
|
299
|
+
await persist(current())
|
|
300
|
+
return view()
|
|
301
|
+
}
|
|
302
|
+
if (input.type === 'save-selection') {
|
|
303
|
+
if (!input.selectionChanges || typeof input.selectionChanges !== 'object' || Array.isArray(input.selectionChanges) || Object.values(input.selectionChanges).some((x) => typeof x !== 'boolean')) throw new ConnectionError('INVALID_SELECTION', 'Invalid model selection')
|
|
304
|
+
await mutate(input, { modelSelection: { ...(current().modelSelection ?? {}), ...input.selectionChanges } })
|
|
305
|
+
return view()
|
|
306
|
+
}
|
|
307
|
+
if (input.type === 'set-enabled') {
|
|
308
|
+
if (typeof input.enabled !== 'boolean') throw new ConnectionError('INVALID_REQUEST', 'Enabled must be a boolean')
|
|
309
|
+
if (input.enabled === (current().enabled !== false)) {
|
|
310
|
+
if (descriptor().revision !== input.expectedRevision && current().operationId !== input.operationId) throw new ConnectionError('SETTINGS_CONFLICT', 'Connection settings have changed')
|
|
311
|
+
} else await mutate(input, { enabled: input.enabled })
|
|
312
|
+
if (input.enabled) { const value = current(); try { await refresh(value.generation, signal) } catch { } } else { cancelWake(); abortRefreshes() }
|
|
313
|
+
return view()
|
|
314
|
+
}
|
|
315
|
+
if (input.type === 'forget-key') { cancelWake(); abortRefreshes(); await mutate(input, { authIdentity: { type: 'missing' } }); return view() }
|
|
316
|
+
if (input.type === 'import-legacy') {
|
|
317
|
+
await prepareLegacy(ctx.settings, input)
|
|
318
|
+
await cutoverLegacy(ctx.settings)
|
|
319
|
+
const value = current()
|
|
320
|
+
try { await refresh(value.generation, signal) } catch { }
|
|
321
|
+
if (current().migration?.phase !== 'active') recoveryError = errorView('MIGRATION_PENDING', 'Migration is prepared; refresh the catalog to finish activation')
|
|
322
|
+
return view()
|
|
323
|
+
}
|
|
324
|
+
if (input.type === 'restore-connection') {
|
|
325
|
+
if (typeof input.operationId !== 'string' || !input.operationId || input.operationId.length > 256) throw new ConnectionError('INVALID_REQUEST', 'An operation ID is required')
|
|
326
|
+
cancelWake(); abortRefreshes()
|
|
327
|
+
const generation = await restore(input.operationId, input.expectedRevision)
|
|
328
|
+
failures = 0
|
|
329
|
+
catalog = undefined
|
|
330
|
+
freshness = 'empty'
|
|
331
|
+
catalogError = undefined
|
|
332
|
+
recoveryError = undefined
|
|
333
|
+
try { await refresh(generation, signal) } catch { await activate() }
|
|
334
|
+
return view()
|
|
335
|
+
}
|
|
336
|
+
if (input.type === 'rollback-legacy') {
|
|
337
|
+
if (!Number.isSafeInteger(input.expectedRevision) || descriptor().revision !== input.expectedRevision) throw new ConnectionError('SETTINGS_CONFLICT', 'Connection settings have changed')
|
|
338
|
+
if (typeof input.operationId !== 'string' || !input.operationId) throw new ConnectionError('INVALID_OPERATION', 'An operation ID is required')
|
|
339
|
+
cancelWake(); abortRefreshes()
|
|
340
|
+
await rollbackLegacy(ctx.settings, async () => { registration?.replace([]) })
|
|
341
|
+
catalog = undefined
|
|
342
|
+
freshness = 'empty'
|
|
343
|
+
activationError = undefined
|
|
344
|
+
recoveryError = undefined
|
|
345
|
+
try { await removeConnectionSnapshot(ctx.settings.documentPath) } catch { }
|
|
346
|
+
durable = undefined
|
|
347
|
+
durableLoaded = true
|
|
348
|
+
return view()
|
|
349
|
+
}
|
|
350
|
+
throw new ConnectionError('INVALID_REQUEST', 'Unknown command')
|
|
351
|
+
}
|
|
352
|
+
const reply = (status, value) => Response.json(value, { status, headers: { 'cache-control': 'no-store' } })
|
|
353
|
+
ctx.inject(['connection'], (web) => {
|
|
354
|
+
for (const [path, methods] of [['/api/cliproxyapi/state', ['GET']], ['/api/cliproxyapi/command', ['POST']], ['/api/cliproxyapi/catalog', ['POST']]]) {
|
|
355
|
+
web.connection.fetch.register({
|
|
356
|
+
path, methods, requestBody: 'buffered', async fetch(request) {
|
|
357
|
+
try {
|
|
358
|
+
if (path.endsWith('/state')) return reply(200, { ok: true, value: await view() })
|
|
359
|
+
if (request.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase() !== 'application/json') return reply(415, { ok: false, error: { ...errorView('INVALID_REQUEST', 'Expected JSON'), details: {} } })
|
|
360
|
+
let input
|
|
361
|
+
try { input = await readJson(request, 16 * 1024) } catch { throw new ConnectionError('INVALID_REQUEST', 'Invalid or oversized JSON') }
|
|
362
|
+
if (path.endsWith('/catalog')) validatePreview(input, ['baseURL', 'apiKey', 'allowEndpointReuse'])
|
|
363
|
+
const value = path.endsWith('/catalog') ? await preview(input, request.signal) : await command(input, request.signal)
|
|
364
|
+
return reply(200, { ok: true, value })
|
|
365
|
+
} catch (error) {
|
|
366
|
+
const code = error instanceof ConnectionError ? error.code : error?.code === 'SETTINGS_CONFLICT' ? 'SETTINGS_CONFLICT' : 'REQUEST_FAILED'
|
|
367
|
+
const status = code === 'SETTINGS_CONFLICT' || code === 'OPERATION_CONFLICT' || code === 'STALE_GENERATION' || code === 'LEGACY_CONFLICT' ? 409 : code === 'REQUEST_FAILED' || code === 'UNKNOWN' || code.startsWith('CATALOG_') ? 502 : 400
|
|
368
|
+
return reply(status, { ok: false, error: { ...errorView(code, error instanceof ConnectionError ? error.message : 'CPA request failed'), details: Number.isInteger(error?.status) ? { status: error.status } : {} } })
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
})
|
|
372
|
+
}
|
|
373
|
+
})
|
|
374
|
+
ctx.on('credentials/reference-updated', () => { void activate().catch(() => { }) })
|
|
375
|
+
ctx.on('credentials/record-updated', () => { void activate().catch(() => { }) })
|
|
376
|
+
ctx.on('settings/document-updated', (ns) => { if (ns === NS) { const value = current(); if (value.enabled === false || value.authIdentity?.type === 'missing') { cancelWake(); abortRefreshes() } void activate().catch(() => { }) } })
|
|
377
|
+
ctx.effect(() => () => { stopped = true; cancelWake(); abortRefreshes(); registration?.() })
|
|
378
|
+
// The plugin fiber is not marked ready while its apply runs, and the settings
|
|
379
|
+
// service only reports rows whose fiber is ready. Reading our row during that
|
|
380
|
+
// window throws and used to latch a recovery error for the whole process, so
|
|
381
|
+
// startup waits for the row instead of reporting a connection that is fine.
|
|
382
|
+
const whenAvailable = async () => {
|
|
383
|
+
for (let attempt = 0; attempt < 600; attempt++) {
|
|
384
|
+
if (stopped) return false
|
|
385
|
+
try { descriptor(); return true } catch { await new Promise((resolve) => setTimeout(resolve, 50)) }
|
|
386
|
+
}
|
|
387
|
+
return false
|
|
388
|
+
}
|
|
389
|
+
void (async () => {
|
|
390
|
+
if (!(await whenAvailable())) return
|
|
391
|
+
// A prepared migration means the process stopped between writing our row and
|
|
392
|
+
// removing the legacy provider. Completing the cutover is the only forward
|
|
393
|
+
// path; leaving it prepared would keep the route deactivated forever.
|
|
394
|
+
if (current().migration?.phase === 'prepared') { try { await cutoverLegacy(ctx.settings) } catch { } }
|
|
395
|
+
if (current().migration?.phase === 'cutover') await cutoverLegacy(ctx.settings)
|
|
396
|
+
if (current().migration?.phase === 'rollback') await rollbackLegacy(ctx.settings, async () => { registration?.replace([]) })
|
|
397
|
+
await load()
|
|
398
|
+
if (stopped) return
|
|
399
|
+
const value = current()
|
|
400
|
+
// The profile patch file can be rebuilt or a row can be denied by a core
|
|
401
|
+
// gate, which erases the saved settings row. The plugin-owned snapshot is
|
|
402
|
+
// offered through state.durable and restore-connection instead of being
|
|
403
|
+
// applied implicitly, because an absent connection can also be deliberate.
|
|
404
|
+
await loadDurable()
|
|
405
|
+
if (!stopped && value.baseURL && value.generation && value.enabled !== false && value.authIdentity?.type !== 'missing' && !['prepared', 'rollback'].includes(value.migration?.phase)) { controller = new AbortController(); try { await refresh(value.generation, controller.signal) } catch { } }
|
|
406
|
+
})().catch(() => {
|
|
407
|
+
// Only a failure after the settings row became readable is a real recovery
|
|
408
|
+
// problem; a successful activation clears it again through activate().
|
|
409
|
+
recoveryError = errorView('MIGRATION_PENDING', 'Connection recovery needs attention')
|
|
410
|
+
})
|
|
411
|
+
}
|