@thebtf/dsh-cliproxyapi-provider 0.0.0-stage → 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/locale/ru.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "meta": {
3
+ "title": "CLIProxyAPI",
4
+ "description": "Подключение шлюза CLIProxyAPI и управление моделями в DeepSeek Harness.",
5
+ "detail": "details/ru.md"
6
+ }
7
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "meta": {
3
+ "title": "CLIProxyAPI",
4
+ "description": "在 DeepSeek Harness 中连接 CLIProxyAPI 网关并管理模型。",
5
+ "detail": "details/zh.md"
6
+ }
7
+ }
package/package.json CHANGED
@@ -1,6 +1,85 @@
1
1
  {
2
2
  "name": "@thebtf/dsh-cliproxyapi-provider",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.4.4",
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
+ }