@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/locale/ru.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,85 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@thebtf/dsh-cliproxyapi-provider",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@thebtf/dsh-cliproxyapi-provider",
|
|
3
|
+
"version": "0.4.3",
|
|
4
|
+
"description": "GUI-configurable CLIProxyAPI provider for DeepSeek Harness with a selectable wire protocol",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./client": "./client.js",
|
|
10
|
+
"./catalog": "./src/catalog.js",
|
|
11
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
12
|
+
"./locale/*.json": "./locale/*.json",
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"client.js",
|
|
18
|
+
"locale",
|
|
19
|
+
"details",
|
|
20
|
+
"cordis.patch.yml",
|
|
21
|
+
"README.md",
|
|
22
|
+
"README_EN.md"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "node --test",
|
|
26
|
+
"check": "node --check src/index.js && node --check src/catalog.js && node --check client.js && node --test",
|
|
27
|
+
"pack:github": "npm pack"
|
|
28
|
+
},
|
|
29
|
+
"dsh": {
|
|
30
|
+
"bundle": {
|
|
31
|
+
"patch": "./cordis.patch.yml"
|
|
32
|
+
},
|
|
33
|
+
"client": {
|
|
34
|
+
"inject": [
|
|
35
|
+
"@deepseek-ai/dsh-client-locale",
|
|
36
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
37
|
+
"@deepseek-ai/dsh-client-ui-primitives",
|
|
38
|
+
"@deepseek-ai/dsh-client-ui-plugin-manager",
|
|
39
|
+
"@deepseek-ai/dsh-api-remotes"
|
|
40
|
+
],
|
|
41
|
+
"platform": "web"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/thebtf/dsh-cliproxyapi-provider.git"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"@deepseek-ai/cordis": "^4.0.4",
|
|
50
|
+
"@deepseek-ai/dsh-credentials": "^0.2.2-rc.2",
|
|
51
|
+
"@deepseek-ai/dsh-llm": "^0.2.2-rc.2",
|
|
52
|
+
"@deepseek-ai/dsh-settings": "^0.2.2-rc.2"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@deepseek-ai/dsh-home-paths": "^0.2.2-rc.2",
|
|
56
|
+
"@deepseek-ai/dsh-llm-pi-ai": "^0.2.2-rc.2",
|
|
57
|
+
"@deepseek-ai/dsh-util-values": "^0.2.2-rc.2",
|
|
58
|
+
"@earendil-works/pi-ai": "^1.3.0",
|
|
59
|
+
"@deepseek-ai/schemastery": "^3.18.2"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@deepseek-ai/dsh-api-remotes": "^0.2.2-rc.2",
|
|
63
|
+
"@deepseek-ai/dsh-client-locale": "^0.2.2-rc.2",
|
|
64
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.2.2-rc.2",
|
|
65
|
+
"@deepseek-ai/dsh-client-ui-primitives": "^0.2.2-rc.2",
|
|
66
|
+
"@deepseek-ai/dsh-client-ui-plugin-manager": "^0.2.2-rc.2",
|
|
67
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.2.2-rc.2",
|
|
68
|
+
"react": "^18.2.0"
|
|
69
|
+
},
|
|
70
|
+
"engines": {
|
|
71
|
+
"node": ">=24.9.0",
|
|
72
|
+
"dsh": "^0.2.2-rc.2"
|
|
73
|
+
},
|
|
74
|
+
"keywords": [
|
|
75
|
+
"dsh",
|
|
76
|
+
"dsh-plugin",
|
|
77
|
+
"deepseek-harness",
|
|
78
|
+
"cliproxyapi",
|
|
79
|
+
"llm"
|
|
80
|
+
],
|
|
81
|
+
"publishConfig": {
|
|
82
|
+
"access": "public"
|
|
83
|
+
},
|
|
84
|
+
"license": "MIT"
|
|
85
|
+
}
|
package/src/adapter.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai'
|
|
2
|
+
import { LlmError, resolveImageAttachmentAccess, resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
|
|
3
|
+
import { createProvider, InMemoryCredentialStore, defaultProviderAuthContext } from '@earendil-works/pi-ai'
|
|
4
|
+
import { createTransport } from './transport.js'
|
|
5
|
+
import { resolveIdentity } from './connection.js'
|
|
6
|
+
import { protocolOf } from './policy.js'
|
|
7
|
+
|
|
8
|
+
const PROVIDER = 'CLIProxyAPI'
|
|
9
|
+
const LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
|
|
10
|
+
|
|
11
|
+
export function createGatewayAdapter(ctx, snapshot, diagnostics, onMissing = () => { }) {
|
|
12
|
+
let profiles = new Map()
|
|
13
|
+
const update = (next) => {
|
|
14
|
+
const captured = structuredClone(next)
|
|
15
|
+
const protocol = protocolOf(captured.policy)
|
|
16
|
+
const models = captured.models.filter((model) => captured.modelSelection?.[model.id] !== false).map((model) => {
|
|
17
|
+
const efforts = model.reasoningEfforts
|
|
18
|
+
const thinkingLevelMap = efforts && Object.fromEntries(LEVELS.flatMap((level) => efforts[level] === undefined ? [[level, null]] : efforts[level] === null ? [] : [[level, efforts[level]]]))
|
|
19
|
+
return {
|
|
20
|
+
id: model.id, name: model.name ?? model.id, provider: PROVIDER, api: protocol,
|
|
21
|
+
baseUrl: captured.baseURL, reasoning: !!efforts, ...(thinkingLevelMap ? { thinkingLevelMap } : {}),
|
|
22
|
+
input: model.inputModalities ?? model.input ?? ['text'], contextWindow: model.contextWindow,
|
|
23
|
+
maxTokens: model.maxTokens, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
24
|
+
compat: { ...captured.policy?.compat, ...model.compat, supportsStrictMode: true },
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
const piProvider = createProvider({
|
|
28
|
+
id: PROVIDER, name: PROVIDER, baseUrl: captured.baseURL, models,
|
|
29
|
+
auth: { apiKey: { name: 'DSH key resolver', resolve: async ({ credential }) => ({ auth: { apiKey: credential?.key ?? 'cpa-keyless' }, source: 'DSH' }) } },
|
|
30
|
+
api: createTransport(protocol, captured.baseURL, diagnostics, globalThis.fetch, captured.authIdentity?.type === 'none'),
|
|
31
|
+
})
|
|
32
|
+
const profile = {
|
|
33
|
+
provider: PROVIDER, displayName: PROVIDER, baseURL: captured.baseURL, piProvider, api: protocol,
|
|
34
|
+
authIdentity: captured.authIdentity, headers: captured.policy?.headers,
|
|
35
|
+
reasoning: captured.policy?.reasoning, thinkingBudgets: captured.policy?.thinkingBudgets,
|
|
36
|
+
cacheRetention: captured.policy?.cacheRetention, transport: captured.policy?.transport,
|
|
37
|
+
timeoutMs: captured.policy?.timeoutMs, websocketConnectTimeoutMs: captured.policy?.websocketConnectTimeoutMs,
|
|
38
|
+
streamIdleTimeoutMs: captured.policy?.streamIdleTimeoutMs ?? 30000,
|
|
39
|
+
maxRequestImageBytes: captured.policy?.maxRequestImageBytes ?? 20 * 1024 * 1024,
|
|
40
|
+
maxRequestImages: captured.policy?.maxRequestImages ?? 50,
|
|
41
|
+
imageOffloadCountQuantum: captured.policy?.imageOffloadCountQuantum ?? Math.min(10, captured.policy?.maxRequestImages ?? 50),
|
|
42
|
+
requestImagePixelBudget: captured.policy?.requestImagePixelBudget ?? 2048 * 2048, requestImageMaxBytes: captured.policy?.requestImageMaxBytes ?? 1024 * 1024,
|
|
43
|
+
retryPolicy: resolveRetryPolicy(captured.policy?.retryPolicy, 'CPA'),
|
|
44
|
+
modelErrors: new Map(), configuredMaxTokens: new Map(),
|
|
45
|
+
}
|
|
46
|
+
profiles = new Map([[PROVIDER, profile]])
|
|
47
|
+
}
|
|
48
|
+
if (snapshot) update(snapshot)
|
|
49
|
+
const adapter = new PiAiAdapter({
|
|
50
|
+
profiles: () => profiles,
|
|
51
|
+
resolveApiKey: async (_provider, profile) => {
|
|
52
|
+
const value = await resolveIdentity(ctx.credentials, profile.authIdentity)
|
|
53
|
+
if (profile.authIdentity?.type !== 'none' && !value) {
|
|
54
|
+
onMissing()
|
|
55
|
+
throw new LlmError('CPA credential is missing', 'MISSING_CREDENTIAL')
|
|
56
|
+
}
|
|
57
|
+
return value
|
|
58
|
+
},
|
|
59
|
+
auth: { credentials: new InMemoryCredentialStore(), authContext: defaultProviderAuthContext },
|
|
60
|
+
resolveAttachments: () => ctx.get('attachments'),
|
|
61
|
+
resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess(attachments, (path) => ctx.get('fs')?.processPathFromHostPath(path), ref),
|
|
62
|
+
onReplayDegrade: () => {
|
|
63
|
+
diagnostics.push({ at: Date.now(), stage: 'replay-degraded' })
|
|
64
|
+
if (diagnostics.length > 50) diagnostics.splice(0, diagnostics.length - 50)
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
return { adapter, update }
|
|
68
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import { mkdir, open, rename, unlink, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { dirname } from 'node:path'
|
|
4
|
+
import { dshCachePath } from '@deepseek-ai/dsh-home-paths'
|
|
5
|
+
|
|
6
|
+
const MAX_CACHE_BYTES = 8 * 1024 * 1024
|
|
7
|
+
const MAX_CATALOG_BYTES = 4 * 1024 * 1024
|
|
8
|
+
|
|
9
|
+
function cachePath(documentPath, generation) {
|
|
10
|
+
if (typeof documentPath !== 'string' || !documentPath || typeof generation !== 'string' || !/^[a-f0-9]{64}$/.test(generation)) throw new TypeError('A document path and valid generation are required')
|
|
11
|
+
const documentHash = createHash('sha256').update(documentPath).digest('hex')
|
|
12
|
+
return dshCachePath('llm-cliproxyapi', documentHash, `${generation}.json`)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function redact(value) {
|
|
16
|
+
if (Array.isArray(value)) return value.map(redact)
|
|
17
|
+
if (!value || typeof value !== 'object') return value
|
|
18
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, /^(?:api[_-]?key|authorization|proxy[_-]?authorization|cookie|set-cookie|password|secret|access[_-]?token|refresh[_-]?token)$/i.test(key) ? '[redacted]' : redact(item)]))
|
|
19
|
+
}
|
|
20
|
+
function validModels(models) {
|
|
21
|
+
return Array.isArray(models) && models.length > 0 && models.every((model) => model && typeof model === 'object' && !Array.isArray(model) && typeof model.id === 'string' && model.id.length > 0 && typeof model.name === 'string' && Number.isSafeInteger(model.contextWindow) && model.contextWindow > 0 && Number.isSafeInteger(model.maxTokens) && model.maxTokens > 0 && Array.isArray(model.inputModalities) && model.inputModalities.length > 0 && model.inputModalities.every((input) => input === 'text' || input === 'image') && (model.reasoningEfforts === undefined || (model.reasoningEfforts && typeof model.reasoningEfforts === 'object' && !Array.isArray(model.reasoningEfforts) && Object.entries(model.reasoningEfforts).every(([level, wire]) => ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'].includes(level) && (typeof wire === 'string' && wire.length > 0 || level === 'off' && wire === null))))) && new Set(models.map((model) => model.id)).size === models.length
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function readCatalogCache(documentPath, generation, baseURL, authIdentity) {
|
|
25
|
+
try {
|
|
26
|
+
const handle = await open(cachePath(documentPath, generation), 'r')
|
|
27
|
+
let data
|
|
28
|
+
try {
|
|
29
|
+
const buffer = Buffer.alloc(MAX_CACHE_BYTES + 1)
|
|
30
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0)
|
|
31
|
+
if (bytesRead > MAX_CACHE_BYTES) return undefined
|
|
32
|
+
data = buffer.subarray(0, bytesRead)
|
|
33
|
+
} finally {
|
|
34
|
+
await handle.close()
|
|
35
|
+
}
|
|
36
|
+
const value = JSON.parse(data.toString('utf8'))
|
|
37
|
+
if (!value || value.schemaVersion !== 1 || value.generation !== generation || value.baseURL !== baseURL || JSON.stringify(value.authIdentity) !== JSON.stringify(authIdentity) || !Number.isFinite(value.fetchedAt) || !Array.isArray(value.raw) || !validModels(value.effective)) return undefined
|
|
38
|
+
return value
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error.code === 'ENOENT' || error instanceof SyntaxError) return undefined
|
|
41
|
+
throw error
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function writeCatalogCache(documentPath, snapshot) {
|
|
46
|
+
const file = cachePath(documentPath, snapshot.generation)
|
|
47
|
+
const raw = redact(snapshot.raw)
|
|
48
|
+
const effective = redact(snapshot.effective)
|
|
49
|
+
if (!Array.isArray(raw) || !validModels(effective)) throw new TypeError('Catalog models are invalid')
|
|
50
|
+
if (Buffer.byteLength(JSON.stringify(raw)) > MAX_CATALOG_BYTES) throw new RangeError('Catalog exceeds 4 MiB')
|
|
51
|
+
const contents = JSON.stringify({ schemaVersion: 1, generation: snapshot.generation, baseURL: snapshot.baseURL, authIdentity: snapshot.authIdentity, fetchedAt: snapshot.fetchedAt, raw, effective })
|
|
52
|
+
if (Buffer.byteLength(contents) > MAX_CACHE_BYTES) throw new RangeError('Catalog cache exceeds 8 MiB')
|
|
53
|
+
await mkdir(dirname(file), { recursive: true })
|
|
54
|
+
const temporary = `${file}.${randomUUID()}.tmp`
|
|
55
|
+
try {
|
|
56
|
+
await writeFile(temporary, contents, { encoding: 'utf8', flag: 'wx', mode: 0o600 })
|
|
57
|
+
await rename(temporary, file)
|
|
58
|
+
} catch (error) {
|
|
59
|
+
await unlink(temporary).catch(() => { })
|
|
60
|
+
throw error
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/catalog.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { credentialField } from './secrets.js'
|
|
2
|
+
|
|
3
|
+
const CANONICAL_REASONING_LEVELS = new Set([
|
|
4
|
+
'off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max',
|
|
5
|
+
])
|
|
6
|
+
|
|
7
|
+
function positiveInteger(...values) {
|
|
8
|
+
for (const value of values) {
|
|
9
|
+
if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function nonEmptyString(...values) {
|
|
14
|
+
for (const value of values) {
|
|
15
|
+
if (typeof value === 'string' && value.trim()) return value.trim()
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeReasoningLevel(value) {
|
|
20
|
+
const wire = nonEmptyString(value)?.toLowerCase()
|
|
21
|
+
if (!wire) return undefined
|
|
22
|
+
if (wire === 'none' || wire === 'off') return { canonical: 'off', wire: 'none' }
|
|
23
|
+
if (wire === 'ultra') return { canonical: 'max', wire }
|
|
24
|
+
if (!CANONICAL_REASONING_LEVELS.has(wire)) return undefined
|
|
25
|
+
return { canonical: wire, wire }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function reasoningEffortsOf(entry) {
|
|
29
|
+
if (!Array.isArray(entry?.supported_reasoning_levels)) return undefined
|
|
30
|
+
const efforts = {}
|
|
31
|
+
for (const raw of entry.supported_reasoning_levels) {
|
|
32
|
+
const normalized = normalizeReasoningLevel(typeof raw === 'string' ? raw : raw?.effort)
|
|
33
|
+
if (normalized && !(normalized.canonical in efforts)) efforts[normalized.canonical] = normalized.wire
|
|
34
|
+
}
|
|
35
|
+
// llm-pi-ai rejects a capability map that offers only "off". Omitting the
|
|
36
|
+
// field correctly describes a non-reasoning model for a hand-declared route.
|
|
37
|
+
if (Object.keys(efforts).every((level) => level === 'off')) return undefined
|
|
38
|
+
return Object.keys(efforts).length ? efforts : undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function inputModalitiesOf(entry, fallback) {
|
|
42
|
+
if (!Array.isArray(entry?.input_modalities)) return [...fallback]
|
|
43
|
+
const modalities = []
|
|
44
|
+
const seen = new Set()
|
|
45
|
+
for (const raw of entry.input_modalities) {
|
|
46
|
+
const value = nonEmptyString(raw)?.toLowerCase()
|
|
47
|
+
if ((value === 'text' || value === 'image') && !seen.has(value)) {
|
|
48
|
+
seen.add(value)
|
|
49
|
+
modalities.push(value)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return modalities.length ? modalities : [...fallback]
|
|
53
|
+
}
|
|
54
|
+
function safeMetadata(value) {
|
|
55
|
+
if (Array.isArray(value)) return value.map(safeMetadata)
|
|
56
|
+
if (!value || typeof value !== 'object') return value
|
|
57
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
58
|
+
key,
|
|
59
|
+
credentialField(key) ? '[redacted]' : safeMetadata(item),
|
|
60
|
+
]))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
export function modelProfileOf(entry, options = {}) {
|
|
65
|
+
const id = nonEmptyString(entry?.slug, entry?.id, entry?.model)
|
|
66
|
+
if (!id || (!options.includeHiddenModels && entry?.visibility === 'hide')) return undefined
|
|
67
|
+
const reasoningEfforts = reasoningEffortsOf(entry)
|
|
68
|
+
return {
|
|
69
|
+
id,
|
|
70
|
+
name: nonEmptyString(entry?.display_name, entry?.name, entry?.description, id),
|
|
71
|
+
contextWindow: positiveInteger(entry?.max_context_window, entry?.context_window, entry?.context_length, options.defaultContextWindow),
|
|
72
|
+
maxTokens: positiveInteger(entry?.max_output_tokens, entry?.max_completion_tokens, entry?.max_tokens, options.defaultMaxTokens),
|
|
73
|
+
input: inputModalitiesOf(entry, options.defaultInput ?? ['text']),
|
|
74
|
+
...(reasoningEfforts ? { reasoningEfforts } : {}),
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function readCodexCatalogWithMetadata(body, options = {}) {
|
|
79
|
+
if (!body || !Array.isArray(body.models)) throw new TypeError('CLIProxyAPI model catalog has no "models" array')
|
|
80
|
+
const models = []
|
|
81
|
+
const discovery = []
|
|
82
|
+
const seen = new Set()
|
|
83
|
+
for (const entry of body.models) {
|
|
84
|
+
const model = modelProfileOf(entry, options)
|
|
85
|
+
if (!model || seen.has(model.id)) continue
|
|
86
|
+
seen.add(model.id)
|
|
87
|
+
const override = options.overrides?.[model.id] ?? {}
|
|
88
|
+
const overrideContext = positiveInteger(override.contextWindow)
|
|
89
|
+
const overrideMax = positiveInteger(override.maxTokens)
|
|
90
|
+
const overrideInput = Array.isArray(override.inputModalities) && override.inputModalities.length && override.inputModalities.every((value) => value === 'text' || value === 'image') ? [...new Set(override.inputModalities)] : undefined
|
|
91
|
+
const overrideEfforts = override.reasoningEfforts && typeof override.reasoningEfforts === 'object' && !Array.isArray(override.reasoningEfforts) ? Object.fromEntries(Object.entries(override.reasoningEfforts).filter(([level, wire]) => CANONICAL_REASONING_LEVELS.has(level) && (typeof wire === 'string' && wire.length > 0 || level === 'off' && wire === null))) : undefined
|
|
92
|
+
if (overrideContext) model.contextWindow = overrideContext
|
|
93
|
+
if (typeof override.name === 'string' && override.name) model.name = override.name
|
|
94
|
+
if (override.reasoningEfforts === false) delete model.reasoningEfforts
|
|
95
|
+
if (override.compat && typeof override.compat === 'object') model.compat = override.compat
|
|
96
|
+
if (overrideMax) model.maxTokens = overrideMax
|
|
97
|
+
if (overrideInput) model.input = overrideInput
|
|
98
|
+
if (overrideEfforts && Object.keys(overrideEfforts).some((level) => level !== 'off')) model.reasoningEfforts = overrideEfforts
|
|
99
|
+
const defaultEffort = normalizeReasoningLevel(override.defaultReasoningEffort ?? entry.default_reasoning_level)?.canonical
|
|
100
|
+
models.push(model)
|
|
101
|
+
discovery.push({
|
|
102
|
+
...model,
|
|
103
|
+
inputModalities: [...model.input],
|
|
104
|
+
...(defaultEffort && model.reasoningEfforts?.[defaultEffort] !== undefined ? { defaultReasoningEffort: defaultEffort } : {}),
|
|
105
|
+
catalogMetadata: safeMetadata(entry),
|
|
106
|
+
catalogSources: {
|
|
107
|
+
contextWindow: overrideContext ? 'override' : positiveInteger(entry.max_context_window, entry.context_window, entry.context_length) ? 'cpa' : 'default',
|
|
108
|
+
maxTokens: overrideMax ? 'override' : positiveInteger(entry.max_output_tokens, entry.max_completion_tokens, entry.max_tokens) ? 'cpa' : 'default',
|
|
109
|
+
inputModalities: overrideInput ? 'override' : Array.isArray(entry.input_modalities) && entry.input_modalities.some((value) => ['text', 'image'].includes(nonEmptyString(value)?.toLowerCase())) ? 'cpa' : 'default',
|
|
110
|
+
reasoningEfforts: overrideEfforts && Object.keys(overrideEfforts).some((level) => level !== 'off') ? 'override' : reasoningEffortsOf(entry) ? 'cpa' : 'default',
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
if (!models.length) throw new TypeError('CLIProxyAPI model catalog contains no usable models')
|
|
115
|
+
return { models, discovery }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function readCodexCatalog(body, options = {}) {
|
|
119
|
+
return readCodexCatalogWithMetadata(body, options).models
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function catalogURL(baseURL) {
|
|
123
|
+
const base = String(baseURL ?? '').trim()
|
|
124
|
+
if (!base) throw new TypeError('CLIProxyAPI baseURL must not be empty')
|
|
125
|
+
const url = new URL(base)
|
|
126
|
+
url.pathname = url.pathname.replace(/\/+$/, '') + '/models'
|
|
127
|
+
url.searchParams.set('client_version', 'dsh-cliproxyapi-provider')
|
|
128
|
+
return url.toString()
|
|
129
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import { mkdir, open, rename, unlink, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { basename, dirname } from 'node:path'
|
|
4
|
+
import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
|
|
5
|
+
import { ConnectionError, validateEndpoint } from './connection.js'
|
|
6
|
+
|
|
7
|
+
// A connection snapshot that only this plugin owns. The settings row lives in
|
|
8
|
+
// the profile patch file and can disappear when the host profile is rebuilt, a
|
|
9
|
+
// row is denied by a core-compatibility gate, or the user reinstalls the
|
|
10
|
+
// bundle. The snapshot is the reconnect source of truth for those cases.
|
|
11
|
+
const MAX_SNAPSHOT_BYTES = 256 * 1024
|
|
12
|
+
const IDENTITY_TYPES = new Set(['none', 'reference', 'record'])
|
|
13
|
+
|
|
14
|
+
function snapshotPath(documentPath) {
|
|
15
|
+
// Keyed by the profile, not by the profile path: a rebuilt profile has a new
|
|
16
|
+
// patch file path but keeps its name, so the snapshot must survive the rebuild.
|
|
17
|
+
const profile = documentPath ? basename(dirname(documentPath)) : 'default'
|
|
18
|
+
const name = /^[\w.-]{1,64}$/.test(profile) ? profile : createHash('sha256').update(profile).digest('hex').slice(0, 16)
|
|
19
|
+
return dshHomePath('llm-cliproxyapi', name + '.json')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function validIdentity(identity) {
|
|
23
|
+
if (!identity || typeof identity !== 'object' || Array.isArray(identity)) return false
|
|
24
|
+
if (!IDENTITY_TYPES.has(identity.type)) return false
|
|
25
|
+
if (identity.type === 'reference') return typeof identity.ref === 'string' && identity.ref.length > 0
|
|
26
|
+
if (identity.type === 'record') return typeof identity.key === 'string' && identity.key.length > 0
|
|
27
|
+
return Object.keys(identity).length === 1
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const plain = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
31
|
+
|
|
32
|
+
export function snapshotOf(value) {
|
|
33
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('A connection is required')
|
|
34
|
+
if (typeof value.baseURL !== 'string' || !value.baseURL) throw new TypeError('A connection endpoint is required')
|
|
35
|
+
if (typeof value.generation !== 'string' || !/^[a-f0-9]{64}$/.test(value.generation)) throw new TypeError('A connection generation is required')
|
|
36
|
+
if (!validIdentity(value.authIdentity)) throw new TypeError('A connection identity is required')
|
|
37
|
+
validateEndpoint(value.baseURL)
|
|
38
|
+
if (value.policy !== undefined && !plain(value.policy)) throw new TypeError('A connection policy must be an object')
|
|
39
|
+
if (value.overrides !== undefined && !plain(value.overrides)) throw new TypeError('Connection overrides must be an object')
|
|
40
|
+
if (value.modelSelection !== undefined && (!plain(value.modelSelection) || Object.values(value.modelSelection).some((selected) => typeof selected !== 'boolean'))) throw new TypeError('A model selection must contain booleans')
|
|
41
|
+
return {
|
|
42
|
+
schemaVersion: 1,
|
|
43
|
+
baseURL: value.baseURL,
|
|
44
|
+
authIdentity: structuredClone(value.authIdentity),
|
|
45
|
+
enabled: value.enabled !== false,
|
|
46
|
+
policy: structuredClone(value.policy ?? {}),
|
|
47
|
+
modelSelection: structuredClone(value.modelSelection ?? {}),
|
|
48
|
+
overrides: structuredClone(value.overrides ?? {}),
|
|
49
|
+
generation: value.generation,
|
|
50
|
+
...(typeof value.operationId === 'string' && value.operationId ? { operationId: value.operationId } : {}),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function writeConnectionSnapshot(documentPath, value) {
|
|
55
|
+
const snapshot = { ...snapshotOf(value), savedAt: Date.now() }
|
|
56
|
+
const contents = JSON.stringify(snapshot)
|
|
57
|
+
if (Buffer.byteLength(contents) > MAX_SNAPSHOT_BYTES) throw new RangeError('Connection snapshot exceeds 256 KiB')
|
|
58
|
+
const file = snapshotPath(documentPath)
|
|
59
|
+
await mkdir(dirname(file), { recursive: true })
|
|
60
|
+
const temporary = `${file}.${randomUUID()}.tmp`
|
|
61
|
+
try {
|
|
62
|
+
await writeFile(temporary, contents, { encoding: 'utf8', flag: 'wx', mode: 0o600 })
|
|
63
|
+
await rename(temporary, file)
|
|
64
|
+
} catch (error) {
|
|
65
|
+
await unlink(temporary).catch(() => { })
|
|
66
|
+
throw error
|
|
67
|
+
}
|
|
68
|
+
return snapshot
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function readConnectionSnapshot(documentPath) {
|
|
72
|
+
let data
|
|
73
|
+
try {
|
|
74
|
+
const handle = await open(snapshotPath(documentPath), 'r')
|
|
75
|
+
try {
|
|
76
|
+
const buffer = Buffer.alloc(MAX_SNAPSHOT_BYTES + 1)
|
|
77
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0)
|
|
78
|
+
if (bytesRead > MAX_SNAPSHOT_BYTES) return undefined
|
|
79
|
+
data = buffer.subarray(0, bytesRead)
|
|
80
|
+
} finally {
|
|
81
|
+
await handle.close()
|
|
82
|
+
}
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error?.code === 'ENOENT') return undefined
|
|
85
|
+
throw error
|
|
86
|
+
}
|
|
87
|
+
let value
|
|
88
|
+
try { value = JSON.parse(data.toString('utf8')) } catch { return undefined }
|
|
89
|
+
if (!value || value.schemaVersion !== 1 || !Number.isFinite(value.savedAt)) return undefined
|
|
90
|
+
try { return snapshotOf(value) } catch { return undefined }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function removeConnectionSnapshot(documentPath) {
|
|
94
|
+
try { await unlink(snapshotPath(documentPath)); return true } catch (error) {
|
|
95
|
+
if (error?.code === 'ENOENT') return false
|
|
96
|
+
throw new ConnectionError('SNAPSHOT_UNAVAILABLE', 'Connection snapshot could not be removed')
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { credentialKey, credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
3
|
+
|
|
4
|
+
const NS = 'llm-cliproxyapi'
|
|
5
|
+
|
|
6
|
+
export class ConnectionError extends Error {
|
|
7
|
+
constructor(code, message) {
|
|
8
|
+
super(message)
|
|
9
|
+
this.name = 'ConnectionError'
|
|
10
|
+
this.code = code
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function validateEndpoint(input) {
|
|
15
|
+
if (typeof input !== 'string' || !input.trim() || input !== input.trim()) throw new ConnectionError('INVALID_URL', 'A valid endpoint is required')
|
|
16
|
+
let parsed
|
|
17
|
+
try { parsed = new URL(input) } catch { throw new ConnectionError('INVALID_URL', 'A valid endpoint is required') }
|
|
18
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.hash) {
|
|
19
|
+
throw new ConnectionError('INVALID_URL', 'Endpoint must be HTTP or HTTPS without user information or fragment')
|
|
20
|
+
}
|
|
21
|
+
for (const key of parsed.searchParams.keys()) {
|
|
22
|
+
if (/(?:key|secret|token|password|authorization|credential)/i.test(key)) throw new ConnectionError('INVALID_URL', 'Endpoint query may not contain secrets')
|
|
23
|
+
}
|
|
24
|
+
return input
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function descriptor(settings) {
|
|
28
|
+
const result = settings.describe().find((row) => row.ns === NS)
|
|
29
|
+
if (!result || !Number.isSafeInteger(result.revision)) throw new ConnectionError('SETTINGS_UNAVAILABLE', 'Connection settings are unavailable')
|
|
30
|
+
return result
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function resolveIdentity(credentials, identity) {
|
|
34
|
+
if (identity?.type === 'none') return undefined
|
|
35
|
+
if (identity?.type === 'reference') return (await credentials.resolve(credentialRef(identity.ref)))?.value
|
|
36
|
+
if (identity?.type === 'record') {
|
|
37
|
+
const record = await credentials.readRecord(identity.key)
|
|
38
|
+
return record?.kind === 'api-key' ? record.key : undefined
|
|
39
|
+
}
|
|
40
|
+
throw new ConnectionError('INVALID_AUTH', 'Unknown credential identity')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function saveConnection(settings, credentials, input) {
|
|
44
|
+
const current = descriptor(settings)
|
|
45
|
+
if (!Number.isSafeInteger(input.expectedRevision)) throw new ConnectionError('SETTINGS_CONFLICT', 'Connection settings have changed')
|
|
46
|
+
if (typeof input.operationId !== 'string' || !input.operationId || input.operationId.length > 256) throw new ConnectionError('INVALID_OPERATION', 'An operation ID is required')
|
|
47
|
+
if (current.value?.operationId === input.operationId) {
|
|
48
|
+
const same = current.value.baseURL === input.baseURL && JSON.stringify(current.value.policy ?? {}) === JSON.stringify(input.policy ?? current.value.policy ?? {}) && Object.entries(input.selectionChanges ?? {}).every(([id, selected]) => current.value.modelSelection?.[id] === selected)
|
|
49
|
+
if (!same) throw new ConnectionError('OPERATION_CONFLICT', 'Operation ID was already used for a different draft')
|
|
50
|
+
const identity = current.value.authIdentity
|
|
51
|
+
if (input.authEdit?.type === 'none' && identity?.type !== 'none' || input.authEdit?.type === 'reference' && (identity?.type !== 'reference' || identity.ref !== input.authEdit.ref) || input.authEdit?.type === 'replace' && (identity?.type !== 'record' || await resolveIdentity(credentials, identity) !== input.authEdit.key)) throw new ConnectionError('OPERATION_CONFLICT', 'Operation ID was already used for a different draft')
|
|
52
|
+
return { revision: current.revision, generation: current.value.generation, operationId: input.operationId }
|
|
53
|
+
}
|
|
54
|
+
if (input.expectedRevision !== current.revision) throw new ConnectionError('SETTINGS_CONFLICT', 'Connection settings have changed')
|
|
55
|
+
const baseURL = validateEndpoint(input.baseURL)
|
|
56
|
+
const previous = current.value ?? {}
|
|
57
|
+
if (input.selectionChanges != null && (typeof input.selectionChanges !== 'object' || Array.isArray(input.selectionChanges) || Object.values(input.selectionChanges).some((v) => typeof v !== 'boolean'))) throw new ConnectionError('INVALID_SELECTION', 'Model selection must contain booleans')
|
|
58
|
+
const edit = input.authEdit
|
|
59
|
+
if (!edit || typeof edit !== 'object') throw new ConnectionError('INVALID_AUTH', 'An explicit authentication action is required')
|
|
60
|
+
let identity
|
|
61
|
+
if (edit.type === 'keep') {
|
|
62
|
+
if (!previous.authIdentity) throw new ConnectionError('INVALID_AUTH', 'There is no existing identity to retain')
|
|
63
|
+
if (previous.baseURL && previous.baseURL !== baseURL && edit.allowEndpointReuse !== true) throw new ConnectionError('AUTH_REUSE_REQUIRED', 'Credential reuse on a new endpoint requires consent')
|
|
64
|
+
identity = previous.authIdentity
|
|
65
|
+
} else if (edit.type === 'none') {
|
|
66
|
+
identity = { type: 'none' }
|
|
67
|
+
} else if (edit.type === 'reference') {
|
|
68
|
+
try { identity = { type: 'reference', ref: credentialRef(edit.ref) } } catch { throw new ConnectionError('INVALID_AUTH', 'Invalid credential reference') }
|
|
69
|
+
if (previous.baseURL && previous.baseURL !== baseURL && previous.authIdentity?.type === 'reference' && previous.authIdentity.ref === identity.ref && edit.allowEndpointReuse !== true) throw new ConnectionError('AUTH_REUSE_REQUIRED', 'Credential reuse on a new endpoint requires consent')
|
|
70
|
+
} else if (edit.type === 'replace') {
|
|
71
|
+
if (typeof edit.key !== 'string' || !edit.key.trim()) throw new ConnectionError('INVALID_AUTH', 'A non-empty key is required')
|
|
72
|
+
const id = 'op-' + createHash('sha256').update(input.operationId).digest('hex')
|
|
73
|
+
const key = credentialKey(NS, id)
|
|
74
|
+
const existing = await credentials.readRecord(key)
|
|
75
|
+
if (existing && (existing.kind !== 'api-key' || existing.key !== edit.key)) throw new ConnectionError('OPERATION_CONFLICT', 'Operation ID already belongs to another credential')
|
|
76
|
+
if (!existing) {
|
|
77
|
+
let record
|
|
78
|
+
try {
|
|
79
|
+
record = await credentials.modifyRecord(key, async (value) => {
|
|
80
|
+
if (value) return undefined
|
|
81
|
+
return { kind: 'api-key', key: edit.key }
|
|
82
|
+
})
|
|
83
|
+
} catch {
|
|
84
|
+
try { record = await credentials.readRecord(key) } catch { }
|
|
85
|
+
if (!record) throw new ConnectionError('UNKNOWN', 'Credential staging outcome is unknown; read state before retrying')
|
|
86
|
+
}
|
|
87
|
+
if (record?.kind !== 'api-key' || record.key !== edit.key) throw new ConnectionError('OPERATION_CONFLICT', 'Operation ID already belongs to another credential')
|
|
88
|
+
}
|
|
89
|
+
identity = { type: 'record', key }
|
|
90
|
+
} else {
|
|
91
|
+
throw new ConnectionError('INVALID_AUTH', 'Unknown authentication action')
|
|
92
|
+
}
|
|
93
|
+
const generation = createHash('sha256').update(input.operationId).update('\0').update(String(current.revision)).digest('hex')
|
|
94
|
+
const values = {
|
|
95
|
+
baseURL, authIdentity: identity, enabled: previous.enabled ?? true,
|
|
96
|
+
policy: input.policy ?? previous.policy ?? {},
|
|
97
|
+
modelSelection: { ...(previous.modelSelection ?? {}), ...(input.selectionChanges ?? {}) },
|
|
98
|
+
operationId: input.operationId, generation,
|
|
99
|
+
}
|
|
100
|
+
const ops = Object.entries(values).map(([key, value]) => ({ op: 'set', path: [key], value }))
|
|
101
|
+
try {
|
|
102
|
+
await settings.mutate(NS, ops, input.expectedRevision)
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (error?.code === 'SETTINGS_CONFLICT') throw error
|
|
105
|
+
try {
|
|
106
|
+
const after = descriptor(settings)
|
|
107
|
+
if (after.value?.operationId === input.operationId && after.value?.generation === generation) return { revision: after.revision, generation, operationId: input.operationId }
|
|
108
|
+
} catch { }
|
|
109
|
+
throw new ConnectionError('UNKNOWN', 'Save outcome is unknown; read state before retrying')
|
|
110
|
+
}
|
|
111
|
+
const after = descriptor(settings)
|
|
112
|
+
if (after.value?.operationId !== input.operationId || after.value?.generation !== generation) throw new ConnectionError('UNKNOWN', 'Save outcome is unknown; read state before retrying')
|
|
113
|
+
return { revision: after.revision, generation, operationId: input.operationId }
|
|
114
|
+
}
|