@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/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
|
+
}
|
package/src/migration.js
ADDED
|
@@ -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
|
+
}
|