@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/src/index.js ADDED
@@ -0,0 +1,513 @@
1
+ import { AsyncResource } from 'node:async_hooks'
2
+ import z from '@deepseek-ai/schemastery'
3
+ import { credentialRef } from '@deepseek-ai/dsh-credentials'
4
+ import { assertUsableApiKey, attributionHeaders } from '@deepseek-ai/dsh-llm'
5
+ import { Config as PiAiConfig } from '@deepseek-ai/dsh-llm-pi-ai'
6
+ import { deepEqualJson } from '@deepseek-ai/dsh-util-values'
7
+ import { catalogURL, readCodexCatalogWithMetadata } from './catalog.js'
8
+ import { installGateway } from './gateway.js'
9
+ import { protocolOf } from './policy.js'
10
+ import { ConnectionError, resolveIdentity, validateEndpoint } from './connection.js'
11
+
12
+ const MAX_CATALOG_BYTES = 4 * 1024 * 1024
13
+ const DISCOVERY_HANDOFF_TTL_MS = 60_000
14
+ const MAX_DISCOVERY_HANDOFFS = 8
15
+ const DISCOVERY_NS = 'llm-cliproxyapi'
16
+ const PI_NS = 'llm-pi-ai'
17
+ const API_KEY_REF = credentialRef('DSH_CLIPROXY_API_KEY')
18
+ const PROVIDER = 'CLIProxyAPI'
19
+
20
+ export const PROFILE_SYNC_HEADER = 'x-dsh-provider-cpa-sync'
21
+ export const PLACEHOLDER_AUTHORIZATION = 'Bearer dsh-cliproxyapi-no-key'
22
+ export const CATALOG_PATH = '/api/cliproxyapi/catalog'
23
+
24
+ export const name = DISCOVERY_NS
25
+ export const inject = ['settings', 'credentials', 'llm', 'timer']
26
+
27
+ export const Config = z.object({
28
+ defaultContextWindow: z.number().step(1).min(1).default(262144),
29
+ defaultMaxTokens: z.number().step(1).min(1).default(32768),
30
+ defaultInput: z.array(z.union(['text', 'image'])).default(['text']),
31
+ headers: z.dict(z.string()).default({}),
32
+ fetchTimeoutMs: z.number().step(1).min(1).default(15000),
33
+ retryInitialMs: z.number().step(1).min(1).default(3000),
34
+ retryMaxMs: z.number().step(1).min(1).default(60000),
35
+ modelSelection: z.dict(z.boolean()).default({}).volatile(),
36
+ refreshIntervalMs: z.number().step(1).min(0).default(300000),
37
+ baseURL: z.string().volatile(),
38
+ enabled: z.boolean().default(true).volatile(),
39
+ authIdentity: z.any().volatile(),
40
+ policy: z.any().volatile(),
41
+ generation: z.string().volatile(),
42
+ operationId: z.string().volatile(),
43
+ overrides: z.any().volatile(),
44
+ migration: z.any().volatile(),
45
+ })
46
+
47
+ function normalizedBaseURL(value) {
48
+ const baseURL = String(value ?? '').trim().replace(/\/+$/, '')
49
+ if (!baseURL) throw new TypeError('CLIProxyAPI baseURL must not be empty')
50
+ let parsed
51
+ try {
52
+ parsed = new URL(baseURL)
53
+ } catch (error) {
54
+ throw new TypeError('CLIProxyAPI baseURL must be a valid URL', { cause: error })
55
+ }
56
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
57
+ throw new TypeError('CLIProxyAPI baseURL must use http or https')
58
+ }
59
+ return baseURL
60
+ }
61
+
62
+ function headerKey(headers, expected) {
63
+ const normalized = expected.toLowerCase()
64
+ return Object.keys(headers ?? {}).find((key) => key.toLowerCase() === normalized)
65
+ }
66
+
67
+ function profileSyncValue(profile) {
68
+ const key = headerKey(profile?.headers, PROFILE_SYNC_HEADER)
69
+ return key === undefined ? undefined : String(profile.headers[key])
70
+ }
71
+
72
+ function profileSynchronizationPending(profile) {
73
+ return profileSyncValue(profile) !== undefined
74
+ }
75
+
76
+ function profileHasRichBootstrap(profile) {
77
+ return profileSyncValue(profile)?.startsWith('rich:') ?? false
78
+ }
79
+
80
+ function mergedHeadersOf(profileHeaders, configuredHeaders) {
81
+ const headers = {}
82
+ for (const source of [configuredHeaders, profileHeaders]) {
83
+ for (const [key, value] of Object.entries(source ?? {})) {
84
+ const existing = headerKey(headers, key)
85
+ if (existing !== undefined) delete headers[existing]
86
+ if (key.toLowerCase() !== PROFILE_SYNC_HEADER) headers[key] = value
87
+ }
88
+ }
89
+ return headers
90
+ }
91
+
92
+ function catalogHeadersOf(profileHeaders, configuredHeaders) {
93
+ const headers = mergedHeadersOf(profileHeaders, configuredHeaders)
94
+ const authorization = headerKey(headers, 'authorization')
95
+ if (authorization !== undefined && headers[authorization] === PLACEHOLDER_AUTHORIZATION) {
96
+ delete headers[authorization]
97
+ }
98
+ return headers
99
+ }
100
+
101
+ function profileHeadersOf(profileHeaders, configuredHeaders, hasApiKey) {
102
+ const headers = mergedHeadersOf(profileHeaders, configuredHeaders)
103
+ const authorization = headerKey(headers, 'authorization')
104
+ if (hasApiKey) {
105
+ if (authorization !== undefined && headers[authorization] === PLACEHOLDER_AUTHORIZATION) {
106
+ delete headers[authorization]
107
+ }
108
+ } else if (authorization === undefined) {
109
+ headers.authorization = PLACEHOLDER_AUTHORIZATION
110
+ }
111
+ return headers
112
+ }
113
+
114
+ async function optionalApiKey(ctx, supplied) {
115
+ const raw = supplied === undefined
116
+ ? (await ctx.credentials.resolve(API_KEY_REF))?.value
117
+ : supplied
118
+ if (raw === undefined || raw.length === 0) return undefined
119
+ return assertUsableApiKey(raw, DISCOVERY_NS, API_KEY_REF)
120
+ }
121
+
122
+ function timedSignal(parent, timeoutMs) {
123
+ const controller = new AbortController()
124
+ const timeoutError = new Error(`CLIProxyAPI model catalog timed out after ${timeoutMs} ms`)
125
+ timeoutError.name = 'TimeoutError'
126
+ const forwardAbort = () => controller.abort(parent.reason)
127
+ if (parent?.aborted) forwardAbort()
128
+ else parent?.addEventListener('abort', forwardAbort, { once: true })
129
+ const timer = setTimeout(() => controller.abort(timeoutError), timeoutMs)
130
+ return {
131
+ signal: controller.signal,
132
+ dispose() {
133
+ clearTimeout(timer)
134
+ parent?.removeEventListener('abort', forwardAbort)
135
+ },
136
+ }
137
+ }
138
+
139
+ async function readBoundedJson(response, maxBytes = MAX_CATALOG_BYTES) {
140
+ const declared = Number(response.headers.get('content-length') ?? Number.NaN)
141
+ if (Number.isFinite(declared) && declared > maxBytes) {
142
+ await response.body?.cancel()
143
+ throw new ConnectionError('CATALOG_TOO_LARGE', 'CPA catalog is too large')
144
+ }
145
+ if (!response.body) return JSON.parse(await response.text())
146
+ const reader = response.body.getReader()
147
+ const chunks = []
148
+ let total = 0
149
+ try {
150
+ for (; ;) {
151
+ const part = await reader.read()
152
+ if (part.done) break
153
+ total += part.value.byteLength
154
+ if (total > maxBytes) throw new ConnectionError('CATALOG_TOO_LARGE', 'CPA catalog is too large')
155
+ chunks.push(part.value)
156
+ }
157
+ } finally {
158
+ await reader.cancel().catch(() => { })
159
+ }
160
+ const bytes = new Uint8Array(total)
161
+ let offset = 0
162
+ for (const chunk of chunks) {
163
+ bytes.set(chunk, offset)
164
+ offset += chunk.byteLength
165
+ }
166
+ return JSON.parse(new TextDecoder().decode(bytes))
167
+ }
168
+
169
+ async function discoverCatalog(ctx, options, suppliedApiKey, parentSignal) {
170
+ const apiKey = await optionalApiKey(ctx, suppliedApiKey)
171
+ const baseURL = normalizedBaseURL(options.baseURL)
172
+ const url = catalogURL(baseURL)
173
+ const request = timedSignal(parentSignal, options.fetchTimeoutMs)
174
+ try {
175
+ let response
176
+ try {
177
+ response = await fetch(url, {
178
+ method: 'GET',
179
+ redirect: 'error',
180
+ signal: request.signal,
181
+ headers: {
182
+ accept: 'application/json',
183
+ ...options.headers,
184
+ ...(apiKey ? { authorization: 'Bearer ' + apiKey } : {}),
185
+ ...attributionHeaders(),
186
+ },
187
+ })
188
+ } catch (error) {
189
+ if (parentSignal?.aborted) throw parentSignal.reason ?? error
190
+ if (request.signal.aborted) throw new ConnectionError('CATALOG_TIMEOUT', `CLIProxyAPI model catalog timed out after ${options.fetchTimeoutMs} ms`)
191
+ throw new ConnectionError('CATALOG_TRANSPORT', 'Could not reach CPA catalog')
192
+ }
193
+ if (!response.ok) {
194
+ const failure = new ConnectionError(response.status === 401 || response.status === 403 ? 'CATALOG_AUTH' : 'CATALOG_TRANSPORT', 'CPA catalog request failed')
195
+ failure.status = response.status
196
+ throw failure
197
+ }
198
+ let body
199
+ try {
200
+ body = await readBoundedJson(response)
201
+ } catch (error) {
202
+ if (parentSignal?.aborted) throw parentSignal.reason ?? error
203
+ if (request.signal.aborted) throw new ConnectionError('CATALOG_TIMEOUT', `CLIProxyAPI model catalog timed out after ${options.fetchTimeoutMs} ms`)
204
+ if (error?.code === 'CATALOG_TOO_LARGE') throw error
205
+ throw new ConnectionError('CATALOG_INVALID_RESPONSE', 'CPA catalog returned invalid JSON')
206
+ }
207
+ try {
208
+ return {
209
+ ...readCodexCatalogWithMetadata(body, options),
210
+ hasApiKey: apiKey !== undefined,
211
+ }
212
+ } catch (error) {
213
+ throw new ConnectionError('CATALOG_INVALID_RESPONSE', 'CPA catalog response is invalid')
214
+ }
215
+ } finally {
216
+ request.dispose()
217
+ }
218
+ }
219
+
220
+ function sortedHeaders(headers) {
221
+ return Object.fromEntries(Object.entries(headers).sort(([left], [right]) => {
222
+ return left.toLowerCase().localeCompare(right.toLowerCase())
223
+ }))
224
+ }
225
+
226
+ function refreshKeyOf(profile, config) {
227
+ if (!profile) return undefined
228
+ return JSON.stringify({
229
+ displayName: profile.displayName,
230
+ api: profile.api,
231
+ baseURL: String(profile.baseURL ?? '').trim().replace(/\/+$/, ''),
232
+ models: profile.models,
233
+ defaultContextWindow: profile.defaultContextWindow,
234
+ defaultMaxTokens: profile.defaultMaxTokens,
235
+ defaultInput: profile.defaultInput,
236
+ sync: profileSyncValue(profile),
237
+ supportsStrictMode: profile.compat?.supportsStrictMode,
238
+ headers: sortedHeaders(catalogHeadersOf(profile.headers, config.headers)),
239
+ })
240
+ }
241
+
242
+ async function profileOf(profile, models, hasApiKey, config) {
243
+ const {
244
+ apiKeyEnv: _previousApiKeyEnv,
245
+ modelOverrides: _previousModelOverrides,
246
+ ...rest
247
+ } = profile
248
+ const candidate = {
249
+ ...rest,
250
+ displayName: PROVIDER,
251
+ api: protocolOf(profile),
252
+ baseURL: normalizedBaseURL(profile.baseURL),
253
+ // This enables pi-ai's explicit strict:false, avoiding upstream auto-strict.
254
+ compat: { ...profile.compat, supportsStrictMode: true },
255
+ models,
256
+ defaultContextWindow: profile.defaultContextWindow ?? config.defaultContextWindow,
257
+ defaultMaxTokens: profile.defaultMaxTokens ?? config.defaultMaxTokens,
258
+ defaultInput: [...(profile.defaultInput ?? config.defaultInput)],
259
+ headers: profileHeadersOf(profile.headers, config.headers, hasApiKey),
260
+ ...(hasApiKey ? { apiKeyEnv: API_KEY_REF } : {}),
261
+ }
262
+ // providers is volatile in DSH 0.2: the Standard Schema view omits it.
263
+ // The callable schema validates the entry and keeps its resolved value.
264
+ return PiAiConfig({ providers: { [PROVIDER]: candidate } }).providers.get()[PROVIDER]
265
+ }
266
+
267
+ function retryDelay(config, failures) {
268
+ return Math.min(config.retryInitialMs * (2 ** Math.max(0, failures - 1)), config.retryMaxMs)
269
+ }
270
+
271
+ export function apply(ctx, config) {
272
+ const validateConfig = (value) => {
273
+ if (!value.defaultInput.length) throw new Error('defaultInput must contain at least one modality')
274
+ if (value.retryMaxMs < value.retryInitialMs) {
275
+ throw new Error('retryMaxMs must be greater than or equal to retryInitialMs')
276
+ }
277
+ }
278
+ validateConfig(config)
279
+ const syncContext = new AsyncResource('dsh-cliproxyapi-sync')
280
+ let source = () => config
281
+ const currentConfig = () => source()
282
+
283
+ const catalogFor = (profile, signal, activeConfig) => discoverCatalog(ctx, {
284
+ baseURL: profile.baseURL,
285
+ defaultContextWindow: activeConfig.defaultContextWindow,
286
+ defaultMaxTokens: activeConfig.defaultMaxTokens,
287
+ defaultInput: activeConfig.defaultInput,
288
+ headers: catalogHeadersOf(profile.headers, activeConfig.headers),
289
+ fetchTimeoutMs: activeConfig.fetchTimeoutMs,
290
+ }, undefined, signal)
291
+
292
+ // DSH 0.2 stores pi-ai providers in the loader entry's volatile form,
293
+ // exposed by settings.describe(), rather than a settings.get() section.
294
+ const currentProfile = () => ctx.settings.describe()
295
+ .find((entry) => entry.ns === PI_NS)?.value?.providers?.[PROVIDER]
296
+
297
+ const currentSelection = (settings) => settings
298
+ .find((entry) => entry.ns === DISCOVERY_NS)?.value?.modelSelection ?? config.modelSelection?.get?.() ?? {}
299
+
300
+ const discoveryHandoffs = new Map()
301
+
302
+ const rememberDiscovery = (baseURL, models) => {
303
+ const now = Date.now()
304
+ for (const [key, handoff] of discoveryHandoffs) {
305
+ if (now - handoff.storedAt > DISCOVERY_HANDOFF_TTL_MS) discoveryHandoffs.delete(key)
306
+ }
307
+ while (discoveryHandoffs.size >= MAX_DISCOVERY_HANDOFFS) {
308
+ discoveryHandoffs.delete(discoveryHandoffs.keys().next().value)
309
+ }
310
+ discoveryHandoffs.set(normalizedBaseURL(baseURL), {
311
+ models: structuredClone(models),
312
+ storedAt: now,
313
+ })
314
+ }
315
+
316
+ const takeDiscovery = (baseURL) => {
317
+ const key = normalizedBaseURL(baseURL)
318
+ const handoff = discoveryHandoffs.get(key)
319
+ discoveryHandoffs.delete(key)
320
+ if (!handoff || Date.now() - handoff.storedAt > DISCOVERY_HANDOFF_TTL_MS) return undefined
321
+ return handoff.models
322
+ }
323
+ const previewKey = async (endpoint, supplied, allowEndpointReuse) => {
324
+ validateEndpoint(endpoint)
325
+ if (supplied !== undefined) {
326
+ if (typeof supplied !== 'string' || Buffer.byteLength(supplied) > 4096) throw new ConnectionError('INVALID_AUTH', 'Invalid key')
327
+ return supplied
328
+ }
329
+ if (allowEndpointReuse !== undefined && typeof allowEndpointReuse !== 'boolean') throw new ConnectionError('INVALID_REQUEST', 'Invalid endpoint reuse')
330
+ const settings = ctx.settings.describe()
331
+ const own = settings.find((entry) => entry.ns === DISCOVERY_NS)?.value
332
+ if (own?.baseURL && own.authIdentity) {
333
+ if (own.baseURL !== endpoint && allowEndpointReuse !== true) return ''
334
+ if (own.authIdentity.type === 'missing') throw new ConnectionError('MISSING_CREDENTIAL', 'CPA credential is missing')
335
+ const key = await resolveIdentity(ctx.credentials, own.authIdentity)
336
+ if (own.authIdentity.type !== 'none' && !key) throw new ConnectionError('MISSING_CREDENTIAL', 'CPA credential is missing')
337
+ return key ?? ''
338
+ }
339
+ const legacy = settings.find((entry) => entry.ns === PI_NS)?.value?.providers?.[PROVIDER]
340
+ if (legacy?.baseURL === endpoint) return (await optionalApiKey(ctx)) ?? ''
341
+ return ''
342
+ }
343
+
344
+
345
+ const discoverModels = async (request, signal) => {
346
+ const activeConfig = currentConfig()
347
+ const catalog = await discoverCatalog(ctx, {
348
+ baseURL: request.baseURL,
349
+ defaultContextWindow: activeConfig.defaultContextWindow,
350
+ defaultMaxTokens: activeConfig.defaultMaxTokens,
351
+ defaultInput: activeConfig.defaultInput,
352
+ headers: {},
353
+ fetchTimeoutMs: activeConfig.fetchTimeoutMs,
354
+ }, await previewKey(request.baseURL, request.apiKey, request.allowEndpointReuse), signal ?? request.signal)
355
+ rememberDiscovery(request.baseURL, catalog.models)
356
+ return catalog.discovery
357
+ }
358
+ ctx.llm.registerModelDiscovery(DISCOVERY_NS, discoverModels)
359
+ installGateway(ctx, currentConfig, async (request, signal, key) => discoverCatalog(ctx, {
360
+ baseURL: request.baseURL,
361
+ defaultContextWindow: request.policy?.defaultContextWindow ?? currentConfig().defaultContextWindow,
362
+ defaultMaxTokens: request.policy?.defaultMaxTokens ?? currentConfig().defaultMaxTokens,
363
+ defaultInput: request.policy?.defaultInput ?? currentConfig().defaultInput,
364
+ overrides: request.overrides,
365
+ headers: request.policy?.headers ?? {},
366
+ fetchTimeoutMs: currentConfig().fetchTimeoutMs,
367
+ }, key ?? '', signal), readBoundedJson, previewKey)
368
+
369
+
370
+ let observedRefreshKey
371
+
372
+ const synchronize = async (signal, authOnly = false) => {
373
+ const activeConfig = currentConfig()
374
+ const settings = ctx.settings.describe()
375
+ const own = settings.find((entry) => entry.ns === DISCOVERY_NS)?.value
376
+ if (own?.baseURL && own.migration?.phase !== 'prepared') return false
377
+ const descriptor = settings.find((entry) => entry.ns === PI_NS)
378
+ const profile = descriptor?.value?.providers?.[PROVIDER]
379
+ if (!profile) return false
380
+ if (!Number.isSafeInteger(descriptor.revision)) throw new Error('CLIProxyAPI cannot synchronize without a settings revision')
381
+ if (authOnly && profileSynchronizationPending(profile)) return true
382
+ if (signal.aborted) throw signal.reason
383
+ const hasApiKey = async () => (await optionalApiKey(ctx)) !== undefined
384
+ let catalog
385
+ if (authOnly) {
386
+ catalog = { models: profile.models, hasApiKey: await hasApiKey() }
387
+ } else if (profileHasRichBootstrap(profile)) {
388
+ const discovered = takeDiscovery(profile.baseURL)
389
+ catalog = discovered
390
+ ? { models: discovered, hasApiKey: await hasApiKey() }
391
+ : await catalogFor(profile, signal, activeConfig)
392
+ } else {
393
+ catalog = await catalogFor(profile, signal, activeConfig)
394
+ }
395
+ if (signal.aborted) throw signal.reason
396
+ const selection = authOnly ? undefined : currentSelection(settings)
397
+ const enabledModels = authOnly ? catalog.models : catalog.models.filter((model) => selection[model.id] !== false)
398
+ if (!enabledModels.length) throw new Error('CLIProxyAPI has no enabled models in the current catalog; the existing profile was not changed')
399
+ const next = await profileOf(profile, enabledModels, catalog.hasApiKey, activeConfig)
400
+ if (signal.aborted) throw signal.reason
401
+ const nextRefreshKey = refreshKeyOf(next, activeConfig)
402
+ if (!deepEqualJson(next, profile)) {
403
+ const previousRefreshKey = observedRefreshKey
404
+ observedRefreshKey = nextRefreshKey
405
+ try {
406
+ await ctx.settings.mutate(PI_NS, [{
407
+ op: 'set',
408
+ path: ['providers', PROVIDER],
409
+ value: next,
410
+ }], descriptor.revision)
411
+ } catch (error) {
412
+ if (observedRefreshKey === nextRefreshKey) observedRefreshKey = previousRefreshKey
413
+ throw error
414
+ }
415
+ } else {
416
+ observedRefreshKey = nextRefreshKey
417
+ }
418
+ return true
419
+ }
420
+
421
+ let stopped = false
422
+ let running = false
423
+ let rerun = false
424
+ let authOnlyRequested = false
425
+ let activeController
426
+ let wakeDispose
427
+ let failures = 0
428
+ let lastError = ''
429
+
430
+ const cancelWake = () => {
431
+ wakeDispose?.()
432
+ wakeDispose = undefined
433
+ }
434
+
435
+ const wakeAfter = (delay) => {
436
+ cancelWake()
437
+ if (delay <= 0) return
438
+ wakeDispose = ctx.timeout(() => {
439
+ wakeDispose = undefined
440
+ rerun = true
441
+ void drain()
442
+ }, delay)
443
+ }
444
+
445
+ const drain = async () => {
446
+ if (running || stopped) return
447
+ running = true
448
+ try {
449
+ while (rerun && !stopped) {
450
+ rerun = false
451
+ const authOnly = authOnlyRequested
452
+ authOnlyRequested = false
453
+ const controller = new AbortController()
454
+ activeController = controller
455
+ try {
456
+ const hasProfile = await synchronize(controller.signal, authOnly)
457
+ failures = 0
458
+ lastError = ''
459
+ if (authOnly) rerun = true
460
+ else if (hasProfile && !rerun) wakeAfter(currentConfig().refreshIntervalMs)
461
+ } catch (error) {
462
+ if (controller.signal.aborted || stopped) continue
463
+ failures += 1
464
+ const message = error instanceof Error ? error.message : String(error)
465
+ if (message !== lastError) {
466
+ ctx.logger.warn('CLIProxyAPI provider refresh failed: ' + message)
467
+ lastError = message
468
+ }
469
+ wakeAfter(retryDelay(currentConfig(), failures))
470
+ break
471
+ } finally {
472
+ if (activeController === controller) activeController = undefined
473
+ }
474
+ }
475
+ } finally {
476
+ running = false
477
+ if (rerun && !wakeDispose && !stopped) void drain()
478
+ }
479
+ }
480
+
481
+ const schedule = ({ authOnly = false } = {}) => {
482
+ if (stopped) return
483
+ if (authOnly) authOnlyRequested = true
484
+ cancelWake()
485
+ rerun = true
486
+ activeController?.abort(new Error('CLIProxyAPI provider refresh superseded'))
487
+ if (!running) syncContext.runInAsyncScope(() => void drain())
488
+ }
489
+
490
+ const scheduleFromSettings = (force = false) => {
491
+ const profile = currentProfile()
492
+ const refreshKey = refreshKeyOf(profile, currentConfig())
493
+ if (!force && refreshKey === observedRefreshKey) return
494
+ observedRefreshKey = refreshKey
495
+ schedule()
496
+ }
497
+
498
+ ctx.on('settings/document-updated', (ns) => {
499
+ if (ns === PI_NS) scheduleFromSettings()
500
+ else if (ns === DISCOVERY_NS) schedule()
501
+ })
502
+ ctx.on('credentials/reference-updated', (ref) => {
503
+ if (ref === API_KEY_REF) schedule({ authOnly: true })
504
+ })
505
+ ctx.effect(() => () => {
506
+ stopped = true
507
+ cancelWake()
508
+ activeController?.abort(new Error('CLIProxyAPI provider plugin disposed'))
509
+ syncContext.emitDestroy()
510
+ })
511
+
512
+ scheduleFromSettings(true)
513
+ }
@@ -0,0 +1,132 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { ConnectionError, validateEndpoint } from './connection.js'
3
+ import { credentialField } from './secrets.js'
4
+ import { PROTOCOLS, validatePolicy } from './policy.js'
5
+
6
+ const OWN = 'llm-cliproxyapi'
7
+ const LEGACY = 'llm-pi-ai'
8
+ const PROVIDER = 'CLIProxyAPI'
9
+ const fingerprintOf = (profile) => createHash('sha256').update(JSON.stringify(profile)).digest('hex')
10
+ const PLACEHOLDER = 'Bearer dsh-cliproxyapi-no-key'
11
+ function portableProfile(value) {
12
+ const profile = structuredClone(value)
13
+ if (profile.headers) {
14
+ for (const [key, header] of Object.entries(profile.headers)) {
15
+ if (key.toLowerCase() === 'x-dsh-provider-cpa-sync' || key.toLowerCase() === 'authorization' && header === PLACEHOLDER) delete profile.headers[key]
16
+ }
17
+ }
18
+ return profile
19
+ }
20
+ function containsSecrets(value) {
21
+ if (!value || typeof value !== 'object') return false
22
+ return Object.entries(value).some(([key, item]) => credentialField(key) || containsSecrets(item))
23
+ }
24
+ function rows(settings) {
25
+ const entries = settings.describe()
26
+ return { own: entries.find((row) => row.ns === OWN), legacy: entries.find((row) => row.ns === LEGACY) }
27
+ }
28
+ export function legacyView(settings) {
29
+ const { legacy } = rows(settings)
30
+ const profile = legacy?.user?.providers?.[PROVIDER]
31
+ if (!profile) return { available: false }
32
+ if (legacy.base?.providers?.[PROVIDER]) return { available: false, reason: 'Legacy provider also exists in a base layer' }
33
+ if (!profile.baseURL) return { available: false, reason: 'Legacy endpoint is missing' }
34
+ if (containsSecrets(portableProfile(profile))) return { available: false, reason: 'Legacy provider contains a secret field requiring manual migration' }
35
+ return { available: true, baseURL: profile.baseURL, revision: legacy.revision, fingerprint: fingerprintOf(profile) }
36
+ }
37
+ async function setOwn(settings, values, revision) {
38
+ await settings.mutate(OWN, Object.entries(values).map(([key, value]) => ({ op: 'set', path: [key], value })), revision)
39
+ }
40
+ export async function prepareLegacy(settings, input) {
41
+ const { own, legacy } = rows(settings)
42
+ if (!own || !legacy || own.revision !== input.expectedRevision || legacy.revision !== input.expectedLegacyRevision) throw new ConnectionError('SETTINGS_CONFLICT', 'Settings changed before migration')
43
+ if (own.value?.baseURL || own.value?.migration) throw new ConnectionError('MIGRATION_CONFLICT', 'Connection already exists')
44
+ const summary = legacyView(settings)
45
+ if (!summary.available || summary.fingerprint !== input.fingerprint) throw new ConnectionError('MIGRATION_CONFLICT', summary.reason ?? 'Legacy provider changed')
46
+ const profile = portableProfile(legacy.user.providers[PROVIDER])
47
+ validateEndpoint(profile.baseURL)
48
+ if (profile.api !== undefined && !PROTOCOLS.includes(profile.api)) throw new ConnectionError('MIGRATION_UNSUPPORTED', 'Legacy wire protocol cannot be migrated')
49
+ const known = new Set(['displayName', 'api', 'baseURL', 'apiKeyEnv', 'models', 'modelOverrides', 'defaultContextWindow', 'defaultMaxTokens', 'defaultInput', 'headers', 'compat', 'retryPolicy', 'reasoning', 'thinkingBudgets', 'streamIdleTimeoutMs', 'maxRequestImageBytes', 'maxRequestImages', 'imageOffloadCountQuantum', 'requestImagePixelBudget', 'requestImageMaxBytes', 'cacheRetention', 'transport', 'timeoutMs', 'websocketConnectTimeoutMs'])
50
+ if (Object.keys(profile).some((key) => !known.has(key))) throw new ConnectionError('MIGRATION_UNSUPPORTED', 'Legacy provider has unsupported fields')
51
+ if (typeof input.operationId !== 'string' || !input.operationId) throw new ConnectionError('INVALID_OPERATION', 'An operation ID is required')
52
+ const generation = createHash('sha256').update(input.operationId).update('\0').update(String(own.revision)).digest('hex')
53
+ const authIdentity = profile.apiKeyEnv ? { type: 'reference', ref: profile.apiKeyEnv } : { type: 'none' }
54
+ const { baseURL, apiKeyEnv, models, modelOverrides, ...policy } = profile
55
+ validatePolicy(policy)
56
+ const overrides = {}
57
+ for (const model of models ?? []) {
58
+ const { id, input, ...rest } = model
59
+ if (typeof id !== 'string' || !id || Object.keys(rest).some((key) => !['name', 'contextWindow', 'maxTokens', 'reasoningEfforts', 'compat'].includes(key))) throw new ConnectionError('MIGRATION_UNSUPPORTED', 'Legacy model has unsupported fields')
60
+ overrides[id] = { ...rest, ...(input === undefined ? {} : { inputModalities: input }) }
61
+ }
62
+ for (const [id, value] of Object.entries(modelOverrides ?? {})) {
63
+ const { input, ...rest } = value
64
+ if (Object.keys(rest).some((key) => !['name', 'contextWindow', 'maxTokens', 'reasoningEfforts', 'compat'].includes(key))) throw new ConnectionError('MIGRATION_UNSUPPORTED', 'Legacy model override has unsupported fields')
65
+ overrides[id] = { ...overrides[id], ...rest, ...(rest.compat && overrides[id]?.compat ? { compat: { ...overrides[id].compat, ...rest.compat } } : {}), ...(input === undefined ? {} : { inputModalities: input }) }
66
+ }
67
+ const migration = { phase: 'prepared', operationId: input.operationId, generation, legacyRevision: legacy.revision, fingerprint: summary.fingerprint, backupFingerprint: fingerprintOf(profile), backup: profile, provenance: 'legacy' }
68
+ await setOwn(settings, { baseURL, authIdentity, enabled: true, generation, operationId: input.operationId, policy, overrides, migration }, own.revision)
69
+ return migration
70
+ }
71
+ // Undo a prepared-but-not-cut-over migration. The legacy provider is still in
72
+ // place at this point, so only our own row is cleared; rollbackLegacy is for the
73
+ // post-cutover case where the legacy entry has to be restored.
74
+ export async function discardPrepared(settings) {
75
+ const { own } = rows(settings)
76
+ const migration = own?.value?.migration
77
+ if (!migration || migration.phase !== 'prepared') return false
78
+ if (own.value.generation !== migration.generation) throw new ConnectionError('MIGRATION_CONFLICT', 'Connection changed after migration; forward repair is required')
79
+ await settings.mutate(OWN, ['baseURL', 'authIdentity', 'generation', 'operationId', 'policy', 'overrides', 'migration'].map((key) => ({ op: 'unset', path: [key] })), own.revision)
80
+ return true
81
+ }
82
+ export async function cutoverLegacy(settings) {
83
+ let { own, legacy } = rows(settings)
84
+ let migration = own?.value?.migration
85
+ if (!migration || !['prepared', 'cutover'].includes(migration.phase)) return false
86
+ const current = legacy?.user?.providers?.[PROVIDER]
87
+ if (legacy?.base?.providers?.[PROVIDER]) throw new ConnectionError('MIGRATION_CONFLICT', 'Legacy provider exists in a base layer')
88
+ if (current && fingerprintOf(current) !== migration.fingerprint) throw new ConnectionError('MIGRATION_CONFLICT', 'Legacy provider changed during migration')
89
+ if (migration.phase === 'prepared') {
90
+ if (!current || legacy.revision !== migration.legacyRevision) throw new ConnectionError('MIGRATION_CONFLICT', 'Legacy provider changed before cutover')
91
+ migration = { ...migration, phase: 'cutover' }
92
+ await setOwn(settings, { migration }, own.revision)
93
+ ; ({ own, legacy } = rows(settings))
94
+ }
95
+ if (legacy?.user?.providers?.[PROVIDER]) await settings.mutate(LEGACY, [{ op: 'unset', path: ['providers', PROVIDER] }], legacy.revision)
96
+ ; ({ legacy } = rows(settings))
97
+ if (legacy?.value?.providers?.[PROVIDER]) throw new ConnectionError('MIGRATION_CONFLICT', 'Legacy provider remains after cutover')
98
+ return true
99
+ }
100
+ export async function finishLegacy(settings) {
101
+ const { own, legacy } = rows(settings)
102
+ if (own?.value?.migration?.phase !== 'cutover' || legacy?.value?.providers?.[PROVIDER]) throw new ConnectionError('MIGRATION_CONFLICT', 'Legacy cutover is not complete')
103
+ await setOwn(settings, { migration: { ...own.value.migration, phase: 'active' } }, own.revision)
104
+ }
105
+
106
+ export async function rollbackLegacy(settings, releaseRoute) {
107
+ let { own, legacy } = rows(settings)
108
+ const migration = own?.value?.migration
109
+ if (!migration || own.value.generation !== migration.generation) throw new ConnectionError('MIGRATION_CONFLICT', 'Connection changed after migration; forward repair is required')
110
+ let ownedRevision = own.revision
111
+ const checkOwner = () => {
112
+ const current = rows(settings).own
113
+ if (current?.revision !== ownedRevision || current.value?.generation !== migration.generation || current.value?.migration?.operationId !== migration.operationId || current.value?.migration?.generation !== migration.generation) throw new ConnectionError('MIGRATION_CONFLICT', 'Connection changed during rollback; forward repair is required')
114
+ }
115
+ if (legacy?.base?.providers?.[PROVIDER]) throw new ConnectionError('MIGRATION_CONFLICT', 'Legacy provider exists in a base layer')
116
+ if (migration.phase !== 'rollback') {
117
+ if (legacy?.value?.providers?.[PROVIDER] || legacy?.user?.providers?.[PROVIDER]) throw new ConnectionError('MIGRATION_CONFLICT', 'Legacy provider already exists')
118
+ await setOwn(settings, { migration: { ...migration, phase: 'rollback' } }, own.revision)
119
+ ownedRevision += 1
120
+ checkOwner()
121
+ }
122
+ await releaseRoute()
123
+ checkOwner()
124
+ ; ({ own, legacy } = rows(settings))
125
+ const existing = legacy?.user?.providers?.[PROVIDER]
126
+ if (legacy?.base?.providers?.[PROVIDER] || (existing && fingerprintOf(existing) !== migration.backupFingerprint) || (!existing && legacy?.value?.providers?.[PROVIDER])) throw new ConnectionError('MIGRATION_CONFLICT', 'Legacy provider appeared during rollback')
127
+ if (!existing) await settings.mutate(LEGACY, [{ op: 'set', path: ['providers', PROVIDER], value: migration.backup }], legacy.revision)
128
+ checkOwner()
129
+ ; ({ own, legacy } = rows(settings))
130
+ if (!legacy?.user?.providers?.[PROVIDER] || fingerprintOf(legacy.user.providers[PROVIDER]) !== migration.backupFingerprint) throw new ConnectionError('MIGRATION_CONFLICT', 'Legacy rollback could not be confirmed')
131
+ await settings.mutate(OWN, ['baseURL', 'authIdentity', 'generation', 'operationId', 'policy', 'overrides', 'migration'].map((key) => ({ op: 'unset', path: [key] })), ownedRevision)
132
+ }