@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/catalog.js
CHANGED
|
@@ -1,129 +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
|
-
}
|
|
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
|
+
}
|
package/src/connection-store.js
CHANGED
|
@@ -1,98 +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
|
-
}
|
|
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
|
+
}
|