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/package.json CHANGED
@@ -1,6 +1,70 @@
1
1
  {
2
2
  "name": "dsh-endpoint",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }