@thebtf/dsh-cliproxyapi-provider 0.4.3 → 0.4.4

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 CHANGED
@@ -1,513 +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
- }
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
+ }