@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/README.md +40 -39
- package/README_EN.md +40 -39
- package/client.js +517 -517
- package/cordis.patch.yml +5 -5
- package/details/en.md +9 -9
- package/details/ru.md +9 -9
- package/details/zh.md +9 -9
- package/locale/en.json +7 -7
- package/locale/ru.json +7 -7
- package/locale/zh.json +7 -7
- package/package.json +85 -85
- package/src/adapter.js +68 -68
- package/src/catalog-store.js +62 -62
- package/src/catalog.js +129 -129
- package/src/connection-store.js +98 -98
- package/src/connection.js +114 -114
- package/src/gateway.js +411 -411
- package/src/index.js +513 -513
- package/src/migration.js +132 -132
- package/src/policy.js +42 -42
- package/src/secrets.js +1 -1
- package/src/transport.js +114 -114
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
|
+
}
|