dsh-endpoint 0.0.0-stage → 1.0.1
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/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +262 -2
- package/README.zh.md +244 -0
- package/SECURITY.md +35 -0
- package/cordis.patch.yml +9 -0
- package/docs/curl.png +0 -0
- package/docs/settings.png +0 -0
- package/examples/opencode.json +18 -0
- package/icon.svg +13 -0
- package/package.json +68 -4
- package/src/admin.js +178 -0
- package/src/client.js +520 -0
- package/src/config.js +95 -0
- package/src/dsh/catalog.js +97 -0
- package/src/index.js +28 -0
- package/src/mirror.js +286 -0
- package/src/openai/convert.js +197 -0
- package/src/openai/errors.js +68 -0
- package/src/openai/media.js +124 -0
- package/src/openai/stream.js +294 -0
- package/src/server.js +630 -0
- package/src/session/key.js +129 -0
- package/src/session/registry.js +209 -0
- package/src/version.js +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,70 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-endpoint",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "OpenAI-compatible HTTP endpoint that forwards model calls through a running DeepSeek Harness",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh",
|
|
7
|
+
"deepseek-harness",
|
|
8
|
+
"dsh-plugin",
|
|
9
|
+
"openai-compatible",
|
|
10
|
+
"openai-api",
|
|
11
|
+
"llm-gateway",
|
|
12
|
+
"endpoint"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": "wangzhongyang",
|
|
16
|
+
"homepage": "https://github.com/wangzhongyang/dsh-endpoint#readme",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/wangzhongyang/dsh-endpoint.git"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/wangzhongyang/dsh-endpoint/issues"
|
|
23
|
+
},
|
|
24
|
+
"icon": "icon.svg",
|
|
25
|
+
"type": "module",
|
|
26
|
+
"main": "src/index.js",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": "./src/index.js",
|
|
29
|
+
"./package.json": "./package.json",
|
|
30
|
+
"./client": "./src/client.js"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"src",
|
|
34
|
+
"cordis.patch.yml",
|
|
35
|
+
"icon.svg",
|
|
36
|
+
"examples",
|
|
37
|
+
"docs",
|
|
38
|
+
"README.md",
|
|
39
|
+
"README.zh.md",
|
|
40
|
+
"CHANGELOG.md",
|
|
41
|
+
"SECURITY.md"
|
|
42
|
+
],
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=22.19.0",
|
|
45
|
+
"dsh": ">=0.2.1-rc.2"
|
|
46
|
+
},
|
|
47
|
+
"dsh": {
|
|
48
|
+
"bundle": {
|
|
49
|
+
"patch": "./cordis.patch.yml"
|
|
50
|
+
},
|
|
51
|
+
"client": {
|
|
52
|
+
"platform": "web"
|
|
53
|
+
},
|
|
54
|
+
"compatibility": {
|
|
55
|
+
"dshReleases": {
|
|
56
|
+
"0.2.1-rc.3": "compatible"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"@deepseek-ai/schemastery": "~3.18.5"
|
|
62
|
+
},
|
|
63
|
+
"scripts": {
|
|
64
|
+
"test": "node --test",
|
|
65
|
+
"prepublishOnly": "node --test"
|
|
66
|
+
},
|
|
67
|
+
"publishConfig": {
|
|
68
|
+
"access": "public"
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/admin.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { readFile, rename, writeFile, mkdir } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/** Fields the settings page may write into the user overlay. */
|
|
5
|
+
const CONFIG_KEYS = ['host', 'port', 'defaultModel', 'modelAliases', 'session', 'limits', 'log']
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The page writes a user overlay (`$DSH_HOME/dsh-endpoint/config.json`) instead
|
|
9
|
+
* of the Cordis entry config: the browser cannot reach the loader, and this is
|
|
10
|
+
* the same two-layer shape other plugins use (bootstrap config + user file).
|
|
11
|
+
*/
|
|
12
|
+
export async function readOverlay(root) {
|
|
13
|
+
try {
|
|
14
|
+
const raw = await readFile(join(root, 'config.json'), 'utf8')
|
|
15
|
+
const parsed = JSON.parse(raw)
|
|
16
|
+
return parsed !== null && typeof parsed === 'object' ? parsed : {}
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if (error?.code === 'ENOENT') return {}
|
|
19
|
+
throw error
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function writeOverlay(root, overlay) {
|
|
24
|
+
await mkdir(root, { recursive: true })
|
|
25
|
+
const file = join(root, 'config.json')
|
|
26
|
+
const tmp = `${file}.tmp`
|
|
27
|
+
await writeFile(tmp, `${JSON.stringify(overlay, null, 2)}\n`, 'utf8')
|
|
28
|
+
await rename(tmp, file)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Deep-merge the user overlay over the entry config for the keys we own. */
|
|
32
|
+
export function mergeConfig(base, overlay) {
|
|
33
|
+
const out = { ...base }
|
|
34
|
+
for (const key of CONFIG_KEYS) {
|
|
35
|
+
if (!Object.hasOwn(overlay, key)) continue
|
|
36
|
+
const next = overlay[key]
|
|
37
|
+
const current = base[key]
|
|
38
|
+
if (isPlainObject(next) && isPlainObject(current)) out[key] = { ...current, ...next }
|
|
39
|
+
else out[key] = next
|
|
40
|
+
}
|
|
41
|
+
return out
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Validate and normalise a patch coming from the settings page.
|
|
46
|
+
* @returns {{ patch: object, restartRequired: boolean }}
|
|
47
|
+
*/
|
|
48
|
+
export function normalisePatch(input, base) {
|
|
49
|
+
if (!isPlainObject(input)) throw new Error('body must be a JSON object')
|
|
50
|
+
const patch = {}
|
|
51
|
+
let restartRequired = false
|
|
52
|
+
let rebind = false
|
|
53
|
+
|
|
54
|
+
if ('host' in input) {
|
|
55
|
+
const host = input.host
|
|
56
|
+
if (typeof host !== 'string' || host.length === 0) throw new Error('host must be a non-empty string')
|
|
57
|
+
if (host !== base.host) rebind = true
|
|
58
|
+
patch.host = host
|
|
59
|
+
}
|
|
60
|
+
if ('port' in input) {
|
|
61
|
+
const port = Number(input.port)
|
|
62
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('port must be an integer 0-65535')
|
|
63
|
+
if (port !== base.port) rebind = true
|
|
64
|
+
patch.port = port
|
|
65
|
+
}
|
|
66
|
+
if ('defaultModel' in input) {
|
|
67
|
+
if (typeof input.defaultModel !== 'string') throw new Error('defaultModel must be a string')
|
|
68
|
+
patch.defaultModel = input.defaultModel
|
|
69
|
+
}
|
|
70
|
+
if ('modelAliases' in input) {
|
|
71
|
+
if (!isPlainObject(input.modelAliases)) throw new Error('modelAliases must be an object')
|
|
72
|
+
for (const [name, target] of Object.entries(input.modelAliases)) {
|
|
73
|
+
if (typeof target !== 'string' || !target.includes('/')) {
|
|
74
|
+
throw new Error(`modelAliases.${name} must be "provider/model"`)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
patch.modelAliases = input.modelAliases
|
|
78
|
+
}
|
|
79
|
+
if ('session' in input) {
|
|
80
|
+
const session = input.session
|
|
81
|
+
if (!isPlainObject(session)) throw new Error('session must be an object')
|
|
82
|
+
const next = {}
|
|
83
|
+
if ('mirror' in session) {
|
|
84
|
+
if (typeof session.mirror !== 'boolean') throw new Error('session.mirror must be a boolean')
|
|
85
|
+
if (session.mirror !== base.session.mirror) restartRequired = true
|
|
86
|
+
next.mirror = session.mirror
|
|
87
|
+
}
|
|
88
|
+
if ('mappingTtlDays' in session) {
|
|
89
|
+
const days = Number(session.mappingTtlDays)
|
|
90
|
+
if (!Number.isFinite(days) || days < 0) throw new Error('session.mappingTtlDays must be >= 0')
|
|
91
|
+
next.mappingTtlDays = days
|
|
92
|
+
}
|
|
93
|
+
if ('workspacePath' in session) {
|
|
94
|
+
if (typeof session.workspacePath !== 'string') throw new Error('session.workspacePath must be a string')
|
|
95
|
+
if (session.workspacePath !== base.session.workspacePath) restartRequired = true
|
|
96
|
+
next.workspacePath = session.workspacePath
|
|
97
|
+
}
|
|
98
|
+
patch.session = next
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { patch, restartRequired, rebind }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Register the settings-page routes on the GUI web server (loopback only). */
|
|
105
|
+
export function registerAdminRoutes(ctx, options) {
|
|
106
|
+
if (typeof ctx?.inject !== 'function') return
|
|
107
|
+
const { root, getConfig, onSave, getState, log } = options
|
|
108
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
109
|
+
webCtx.effect(() =>
|
|
110
|
+
webCtx.webServer.register({
|
|
111
|
+
kind: 'prefix',
|
|
112
|
+
path: '/dsh-endpoint',
|
|
113
|
+
handler: (req, res) => {
|
|
114
|
+
void handleAdmin(req, res, { root, getConfig, onSave, getState, log })
|
|
115
|
+
},
|
|
116
|
+
}),
|
|
117
|
+
)
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function handleAdmin(req, res, { root, getConfig, onSave, getState, log }) {
|
|
122
|
+
if (!isLoopback(req.socket?.remoteAddress)) {
|
|
123
|
+
return sendJson(res, 403, { error: 'loopback only' })
|
|
124
|
+
}
|
|
125
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
126
|
+
try {
|
|
127
|
+
if (req.method === 'GET' && url.pathname === '/dsh-endpoint/state') {
|
|
128
|
+
return sendJson(res, 200, await getState())
|
|
129
|
+
}
|
|
130
|
+
if (req.method === 'GET' && url.pathname === '/dsh-endpoint/config') {
|
|
131
|
+
return sendJson(res, 200, getConfig())
|
|
132
|
+
}
|
|
133
|
+
if (req.method === 'PUT' && url.pathname === '/dsh-endpoint/config') {
|
|
134
|
+
const body = await readJson(req)
|
|
135
|
+
const { patch, restartRequired, rebind } = normalisePatch(body, getConfig())
|
|
136
|
+
const current = await readOverlay(root)
|
|
137
|
+
const overlay = { ...current, ...patch, ...(patch.session ? { session: { ...(current.session ?? {}), ...patch.session } } : {}) }
|
|
138
|
+
await writeOverlay(root, overlay)
|
|
139
|
+
const effective = mergeConfig(getConfig(), overlay)
|
|
140
|
+
await onSave?.(effective, patch)
|
|
141
|
+
log?.(
|
|
142
|
+
'info',
|
|
143
|
+
`settings page saved config${rebind ? ' (listener rebound)' : ''}${restartRequired ? ' (restart required)' : ''}`,
|
|
144
|
+
)
|
|
145
|
+
return sendJson(res, 200, { saved: true, restartRequired, rebound: rebind, config: effective })
|
|
146
|
+
}
|
|
147
|
+
return sendJson(res, 404, { error: 'not found' })
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return sendJson(res, 400, { error: String(error?.message ?? error) })
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isLoopback(address) {
|
|
154
|
+
if (typeof address !== 'string') return false
|
|
155
|
+
return address === '::1' || address === '127.0.0.1' || address.startsWith('127.') || address.startsWith('::ffff:127.')
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isPlainObject(value) {
|
|
159
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function readJson(req) {
|
|
163
|
+
const chunks = []
|
|
164
|
+
let size = 0
|
|
165
|
+
for await (const chunk of req) {
|
|
166
|
+
size += chunk.length
|
|
167
|
+
if (size > 256 * 1024) throw new Error('body too large')
|
|
168
|
+
chunks.push(chunk)
|
|
169
|
+
}
|
|
170
|
+
if (chunks.length === 0) return {}
|
|
171
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function sendJson(res, status, payload) {
|
|
175
|
+
const body = JSON.stringify(payload)
|
|
176
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(body) })
|
|
177
|
+
res.end(body)
|
|
178
|
+
}
|