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 ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ ## [1.0.1] - 2026-11-02
4
+
5
+ - `/healthz` and the settings page report the running version.
6
+ - `limits.maxTextFileBytes` (default 1 MiB) controls how much of one inline text
7
+ file is inlined; the request body cap remains the hard ceiling.
8
+
9
+ ## [1.0.0] - 2026-11-02
10
+
11
+ First public release.
12
+
13
+ - OpenAI-compatible `POST /v1/chat/completions` (streaming and non-streaming) and
14
+ `GET /v1/models`, every call forwarded through `ctx.llm.stream()`.
15
+ - Tool calls pass through both ways; inline images and text files are supported.
16
+ - Stable conversation identity and read-only session mirroring into DSH.
17
+ - Settings page (Chinese/English) with in-place listener rebind.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 wangzhongyang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,263 @@
1
- # Temporary Holding Version
1
+ # dsh-endpoint
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm](https://img.shields.io/npm/v/dsh-endpoint)](https://www.npmjs.com/package/dsh-endpoint)
4
+ [![license](https://img.shields.io/npm/l/dsh-endpoint)](LICENSE)
5
+ [![ci](https://github.com/wangzhongyang/dsh-endpoint/actions/workflows/ci.yml/badge.svg)](https://github.com/wangzhongyang/dsh-endpoint/actions/workflows/ci.yml)
6
+
7
+ Expose the model providers and models already configured in DeepSeek Harness as an
8
+ OpenAI-compatible HTTP endpoint.
9
+
10
+ The endpoint only forwards. It holds no provider credentials and never talks to a
11
+ provider directly: every model call goes through `ctx.llm.stream()`, so DSH's provider
12
+ adapters (credentials, protocol differences, retries, image handling, token usage) keep
13
+ owning the real request.
14
+
15
+ [中文](README.zh.md)
16
+
17
+ ## Architecture
18
+
19
+ ```
20
+ OpenAI-compatible client
21
+ │ POST /v1/chat/completions
22
+ ▼
23
+ ┌────────────────────── DSH host process ───────────────────────┐
24
+ │ dsh-endpoint (Cordis plugin) │
25
+ │ HTTP listener → OpenAI mapping → SessionMirror → ctx.llm │
26
+ │ │ │
27
+ │ dsh-llm-pi-ai / dsh-llm-deepseek-* │
28
+ └──────────────────────────────────────────┼─────────────────────┘
29
+ ▼
30
+ provider API
31
+ ```
32
+
33
+ - **Plugin only.** No second process, no second credential store.
34
+ - **Sessions are real.** Each conversation is mirrored into a DSH session, so it shows
35
+ up in the sidebar and can be inspected.
36
+ - **Read-only mirrors.** The mirrored session holds the persistence write handle, so the
37
+ GUI refuses to resume it (`already owned by an active write handle`).
38
+
39
+ ## Install
40
+
41
+ Requires DeepSeek Harness Desktop (web profile) 0.2.1-rc.x.
42
+
43
+ From npm:
44
+
45
+ ```sh
46
+ dsh plugin --profile web add dsh-endpoint
47
+ ```
48
+
49
+ From a checkout (useful while developing):
50
+
51
+ ```sh
52
+ dsh plugin --profile web add /absolute/path/to/dsh-endpoint
53
+ ```
54
+
55
+ In the Desktop app you can also use **Settings → Plugins** and install it from the
56
+ market once the package is listed.
57
+
58
+ > **No authentication, no rate limiting.** The endpoint spends your configured
59
+ > model quota for anyone who can reach it. Keep it on `127.0.0.1` unless you put
60
+ > it behind a proxy you control, and disable the plugin to switch it off.
61
+
62
+ Restart the app after installing: host plugins are ESM modules, and reinstalling does
63
+ not replace the module already loaded in the running process.
64
+
65
+ ## Settings page
66
+
67
+ **Settings → Model Endpoint** (a custom section, same level as plugin pages).
68
+
69
+ ![dsh-endpoint settings page](docs/settings.png)
70
+
71
+ | Field | Meaning |
72
+ |---|---|
73
+ | Listen address | `127.0.0.1` (local only) or `0.0.0.0` (all interfaces) |
74
+ | Port | default `43130`; the GUI owns `43129` |
75
+ | Default model | used when a request omits `model` |
76
+ | Model aliases | JSON map, e.g. `{"gpt-4o": "opencode-go/deepseek-v4.1-flash"}` |
77
+ | Session mirror | write each API conversation into a DSH session |
78
+ | Mapping retention | days an external conversation id keeps its session after last use |
79
+ | Workspace directory | workspace that groups mirrored sessions |
80
+
81
+ Saving applies immediately: **changing the listen address or port rebinds the listener
82
+ in place** (the old listener closes, the new one binds, and a failed bind rolls back to
83
+ the previous address). Switching `127.0.0.1 ↔ 0.0.0.0` takes the same path. Toggling the
84
+ mirror or changing the workspace directory still needs a plugin reload, because those are
85
+ built at startup.
86
+
87
+ In-flight streaming responses are dropped when the listener moves.
88
+
89
+ The page reads and writes two loopback-only routes: `GET /dsh-endpoint/state` and
90
+ `GET|PUT /dsh-endpoint/config`. Saves go to the user layer
91
+ `$DSH_HOME/dsh-endpoint/config.json`, which is merged over the plugin config at startup.
92
+
93
+ ## HTTP API
94
+
95
+ | Method | Path | Notes |
96
+ |---|---|---|
97
+ | `GET` | `/healthz` | liveness + registered providers |
98
+ | `GET` | `/v1/models` | `provider/model` ids plus aliases |
99
+ | `GET` | `/v1/models/{id}` | one model |
100
+ | `POST` | `/v1/chat/completions` | streaming and non-streaming |
101
+
102
+ Model ids are `provider/model`, e.g. `opencode-go/deepseek-v4.1-flash`.
103
+
104
+ **Supported:** `messages` (system/user/assistant/tool), `tools` and `tool_calls`
105
+ pass-through, `temperature`, `max_tokens` / `max_completion_tokens`, `stream`,
106
+ `stream_options.include_usage`, `stop` (truncated endpoint-side because not every
107
+ adapter accepts it).
108
+
109
+ **Rejected explicitly:** `n > 1`, `tool_choice` other than `"auto"`, remote (`http(s)`) media
110
+ URLs, `file_id` references, and binary documents (PDF/DOCX).
111
+
112
+ ## Attachments
113
+
114
+ Inline base64 only — the endpoint never fetches remote URLs on a client's behalf.
115
+
116
+ | Input | Handling |
117
+ |---|---|
118
+ | `image_url` with `data:image/{png,jpeg,webp,gif};base64,…` | decoded → `ctx.attachments.saveImages` → `ImageBlock`; the adapter resizes per route |
119
+ | `image_url` with `http(s)://…` | 400: send a data URL instead |
120
+ | `file` part with `file_data` of `text/*`, JSON, YAML, XML, JS, SQL | inlined as a `text` block (512 KiB cap, filename preserved) |
121
+ | `file` part with a binary document (PDF, DOCX, …) | 400: text extraction is not implemented |
122
+ | `file` part with `file_id` | 400: the Files API is not implemented |
123
+
124
+ Attachments live in DSH's attachment store, so the mirrored session keeps a compact reference
125
+ instead of megabytes of base64.
126
+
127
+ ### Limits
128
+
129
+ | Limit | Value | Source |
130
+ |---|---|---|
131
+ | Request body | 8 MiB | `limits.maxBodyBytes`, a plugin default |
132
+ | One inline text file | 1 MiB, truncated with a marker | `limits.maxTextFileBytes`, a plugin default |
133
+ | One image | 20 MiB | DSH attachment service (not this plugin) |
134
+ | Images per message | 20 | DSH attachment service |
135
+ | Total image bytes per message | 200 MiB | DSH attachment service |
136
+ | Image pixels / longest side | 64 MP / 8192 px | DSH attachment service |
137
+
138
+ The text-file cap is a prompt-size guard, not a protocol limit: the body is already
139
+ bounded by `maxBodyBytes`, and a file near the cap is far past most models' context
140
+ windows. Truncation is announced in the inlined text, so the model knows it is partial.
141
+
142
+ **Ignored on purpose:** every other unknown field (`top_p`, `seed`, `service_tier`,
143
+ `metadata`, …). Real clients send fields this build does not model; rejecting them would
144
+ break more than it protects, so they are logged and dropped rather than silently applied.
145
+
146
+ ## Session identity
147
+
148
+ Resolution order, first hit wins:
149
+
150
+ 1. `x-dsh-session-id` (this plugin's own header)
151
+ 2. known agent headers: `x-session-id`, `x-conversation-id`, `x-chat-id`, `x-thread-id`,
152
+ `x-agent-session-id`, `x-claude-code-session-id`, `x-codex-session-id`
153
+ 3. body fields: `session_id`, `conversation_id`, `chat_id`, `thread_id`,
154
+ `metadata.session_id`, `metadata.conversation_id`, `user`
155
+ 4. content fingerprint: `sha256(model + first user message)` — deterministic across
156
+ requests and restarts
157
+ 5. only when there is no user message at all, a fresh id is minted
158
+
159
+ `x-request-id`, `x-correlation-id` and `x-trace-id` are never treated as session ids.
160
+
161
+ Every response echoes `x-dsh-session-id`. Clients may send it back to pin the
162
+ conversation; clients that send nothing still get a stable id from step 4.
163
+
164
+ Only new user/tool messages are appended to the mirrored session (assistant messages are
165
+ always the ones this endpoint produced), so logs grow per turn instead of quadratically.
166
+ If the client's user history diverges from what was recorded, a new DSH session is
167
+ started and the divergence is logged.
168
+
169
+ ## Supported clients
170
+
171
+ Any client that can point an OpenAI-compatible provider at a custom base URL:
172
+
173
+ ```bash
174
+ export OPENAI_BASE_URL=http://127.0.0.1:43130/v1
175
+ export OPENAI_API_KEY=anything # there is no auth; any placeholder works
176
+ ```
177
+
178
+ | Client | How to configure | Status |
179
+ |---|---|---|
180
+ | `curl` / any HTTP client | POST the body directly | verified |
181
+ | OpenAI Python SDK | `OpenAI(base_url="http://127.0.0.1:43130/v1", api_key="any")` | request shape verified |
182
+ | OpenAI Node SDK | `new OpenAI({ baseURL: "http://127.0.0.1:43130/v1", apiKey: "any" })` | request shape verified |
183
+ | Cline (VS Code) | Provider: OpenAI Compatible → Base URL + any key | expected; not yet end-to-end tested |
184
+ | Roo Code (VS Code) | Provider: OpenAI Compatible | expected; not yet end-to-end tested |
185
+ | Continue (VS Code / JetBrains) | `provider: openai`, `apiBase: http://127.0.0.1:43130/v1` | expected; not yet end-to-end tested |
186
+ | OpenCode (CLI) | custom provider in `opencode.json`, see below | **verified**, tool loop included |
187
+ | Cherry Studio | Add provider → OpenAI → API Host | expected |
188
+ | Chatbox | Model provider → OpenAI API → API Host | expected |
189
+ | LobeChat | OpenAI provider → proxy/base URL | expected |
190
+ | Open WebUI | Connections → OpenAI → Base URL | expected |
191
+
192
+ ### OpenCode
193
+
194
+ Model ids contain a slash, which breaks `provider/model` parsing on the client, so add a
195
+ slash-free alias first (Settings → Model Endpoint → Model aliases), e.g.
196
+ `{"flash": "opencode-go/deepseek-v4.1-flash"}`. Then point a project `opencode.json` at the
197
+ endpoint:
198
+
199
+ ```json
200
+ {
201
+ "provider": {
202
+ "dsh": {
203
+ "name": "DSH Endpoint",
204
+ "npm": "@ai-sdk/openai-compatible",
205
+ "options": { "baseURL": "http://127.0.0.1:43130/v1", "apiKey": "anything" },
206
+ "models": { "flash": { "name": "DeepSeek via DSH" } }
207
+ }
208
+ }
209
+ }
210
+ ```
211
+
212
+ ```sh
213
+ opencode run -m dsh/flash "list the files here and tell me which is largest"
214
+ ```
215
+
216
+ A ready-to-copy file lives at [`examples/opencode.json`](examples/opencode.json).
217
+
218
+ OpenCode sends `x-session-id`, which the endpoint already treats as the conversation key,
219
+ so a whole multi-step tool loop lands in one mirrored session.
220
+
221
+ Tool calls are forwarded both ways (`tools` in, `tool_calls` out); OpenCode's shell/read
222
+ tool loop has been verified end to end.
223
+
224
+ ## Limitations
225
+
226
+ - No authentication and no rate limiting. The stop switch is disabling the plugin. Keep
227
+ it on `127.0.0.1`; binding `0.0.0.0` lets anyone who can reach the port spend your quota.
228
+ - Inline images and text files only; remote media URLs and binary documents are refused.
229
+ - `n > 1`, `tool_choice` other than `auto`, `logprobs`, `top_p`, `seed` are not applied.
230
+ - Mirrored sessions cannot be continued in the GUI by design.
231
+ - Pinned to DSH internal packages (`0.2.1-rc.x`); API changes there can break this plugin.
232
+
233
+ ## Development
234
+
235
+ ```sh
236
+ pnpm install
237
+ node --test # 47 cases, no DSH required
238
+ node spike/mock-provider.mjs 44001 # local fake provider
239
+ ```
240
+
241
+ `spike/` also contains the P0 harness that creates a session in a throwaway `DSH_HOME`,
242
+ persists it, restores it, and checks that GUI takeover is refused (`spike/run.sh`).
243
+
244
+ ## Verified / not verified
245
+
246
+ Verified on a real Desktop install:
247
+
248
+ - `/v1/models` (31 models across `opencode-go`, `deepseek-official`, `deepseek-account`)
249
+ - non-streaming and streaming chat with usage
250
+ - the same conversation twice → the same `x-dsh-session-id`
251
+ - mirrored session persists with `turn/start → step/start → user → assistant → step/end →
252
+ turn/end`, second turn appends only the new user message
253
+ - mirrored sessions appear in the sidebar workspace
254
+ - `agentLoop.resume` refused while the write handle is held
255
+ - settings page: section registered, config read/write round-trip
256
+ - live listener rebind (`43130 → 43131 → 43130`) without restarting the app
257
+ - inline image input (a 1152×883 screenshot was described correctly)
258
+ - OpenCode driving a full tool loop (two shell calls in one turn, mirrored with tool results)
259
+
260
+ Not verified yet:
261
+
262
+ - other IDE agents (Cline, Roo Code, Continue)
263
+ - multiple concurrent conversations against one provider quota
package/README.zh.md ADDED
@@ -0,0 +1,244 @@
1
+ # dsh-endpoint
2
+
3
+ [![npm](https://img.shields.io/npm/v/dsh-endpoint)](https://www.npmjs.com/package/dsh-endpoint)
4
+ [![license](https://img.shields.io/npm/l/dsh-endpoint)](LICENSE)
5
+ [![ci](https://github.com/wangzhongyang/dsh-endpoint/actions/workflows/ci.yml/badge.svg)](https://github.com/wangzhongyang/dsh-endpoint/actions/workflows/ci.yml)
6
+
7
+ 把 DeepSeek Harness 里已经配置好的模型供应商和模型,暴露成一个 OpenAI 兼容的 HTTP 端点。
8
+
9
+ 端点只负责转发:它不持有任何 provider 凭据,也不直接访问三方 API。每一次模型调用都走
10
+ `ctx.llm.stream()`,凭据、协议差异、重试、图片处理、用量统计仍然由 DSH 的 provider 适配器负责。
11
+
12
+ [English](README.md)
13
+
14
+ ## 架构
15
+
16
+ ```
17
+ OpenAI 兼容客户端
18
+ │ POST /v1/chat/completions
19
+ ▼
20
+ ┌────────────────────── DSH Host 进程 ──────────────────────────┐
21
+ │ dsh-endpoint(Cordis 插件) │
22
+ │ HTTP listener → OpenAI 映射 → SessionMirror → ctx.llm │
23
+ │ │ │
24
+ │ dsh-llm-pi-ai / dsh-llm-deepseek-* │
25
+ └──────────────────────────────────────────┼─────────────────────┘
26
+ ▼
27
+ 三方模型 API
28
+ ```
29
+
30
+ - **只做插件**:没有第二个进程,也没有第二套凭据。
31
+ - **会话是真的**:每段对话镜像成一条 DSH 会话,侧栏可见、可查看。
32
+ - **镜像只读**:镜像会话持有持久化写句柄,GUI 里点「继续」会被拒
33
+ (`already owned by an active write handle`)。
34
+
35
+ ## 安装
36
+
37
+ 需要 DeepSeek Harness Desktop(web profile)0.2.1-rc.x。
38
+
39
+ 从 npm 安装:
40
+
41
+ ```sh
42
+ dsh plugin --profile web add dsh-endpoint
43
+ ```
44
+
45
+ 从源码目录安装(开发时用):
46
+
47
+ ```sh
48
+ dsh plugin --profile web add /absolute/path/to/dsh-endpoint
49
+ ```
50
+
51
+ 在 Desktop 里也可以走 **设置 → 插件**,等包进入市场后直接搜 `dsh-endpoint`。
52
+
53
+ > **没有鉴权、没有限流。** 谁能连上这个端口,就能花你配置的模型额度。除非前面有你自己
54
+ > 控制的反向代理,否则请保持在 `127.0.0.1`;停用插件就是关闭它。
55
+
56
+ 安装后要重启应用:宿主插件是 ESM 模块,重装只换磁盘快照,不换已经加载进进程的模块。
57
+
58
+ ## 设置页
59
+
60
+ **设置 → 模型端点**(自定义分区,和插件页同级)。
61
+
62
+ ![dsh-endpoint 设置页](docs/settings.png)
63
+
64
+ | 字段 | 含义 |
65
+ |---|---|
66
+ | 监听地址 | `127.0.0.1`(仅本机)或 `0.0.0.0`(所有网卡) |
67
+ | 端口 | 默认 `43130`;GUI 占用 `43129` |
68
+ | 默认模型 | 请求不带 `model` 时使用 |
69
+ | 模型别名 | JSON 映射,例如 `{"gpt-4o": "opencode-go/deepseek-v4.1-flash"}` |
70
+ | 会话镜像 | 把每段 API 对话写成一条 DSH 会话 |
71
+ | 映射保留 | 外部会话 ID 超过多少天没再用就解绑 |
72
+ | 工作区目录 | 镜像会话归属的 workspace |
73
+
74
+ 保存即时生效:**改监听地址或端口会就地重绑 listener**(先关旧的、再绑新的,绑定失败回滚到
75
+ 原地址)。`127.0.0.1 ↔ 0.0.0.0` 走同一条路径。切换镜像开关、改工作区目录仍然需要重载插件,
76
+ 因为它们在启动时构建。
77
+
78
+ 重绑时正在进行的流式响应会断开。
79
+
80
+ 页面通过两条 loopback 路由读写:`GET /dsh-endpoint/state`、`GET|PUT /dsh-endpoint/config`。
81
+ 保存写入用户层 `$DSH_HOME/dsh-endpoint/config.json`,启动时叠加在插件 config 之上。
82
+
83
+ ## HTTP 接口
84
+
85
+ | 方法 | 路径 | 说明 |
86
+ |---|---|---|
87
+ | `GET` | `/healthz` | 存活 + 已注册 provider |
88
+ | `GET` | `/v1/models` | `provider/model` 列表 + 别名 |
89
+ | `GET` | `/v1/models/{id}` | 单个模型 |
90
+ | `POST` | `/v1/chat/completions` | 流式与非流式 |
91
+
92
+ 模型名为 `provider/model`,例如 `opencode-go/deepseek-v4.1-flash`。
93
+
94
+ **支持**:`messages`(system/user/assistant/tool)、`tools` 与 `tool_calls` 透传、
95
+ `temperature`、`max_tokens` / `max_completion_tokens`、`stream`、
96
+ `stream_options.include_usage`、`stop`(端侧截断,因为不是所有适配器都接受它)。
97
+
98
+ **明确拒绝**:`n > 1`、`tool_choice` 非 `"auto"`、远程(`http(s)`)媒体 URL、`file_id`
99
+ 引用、二进制文档(PDF/DOCX)。
100
+
101
+ ## 附件
102
+
103
+ 只接受内联 base64——端点不会代替客户端去下载远程地址。
104
+
105
+ | 输入 | 处理 |
106
+ |---|---|
107
+ | `image_url` + `data:image/{png,jpeg,webp,gif};base64,…` | 解码 → `ctx.attachments.saveImages` → `ImageBlock`,由适配器按路由缩放 |
108
+ | `image_url` + `http(s)://…` | 400,要求改用 data URL |
109
+ | `file` 部件的 `file_data` 为 `text/*`、JSON、YAML、XML、JS、SQL | 直接内联成 `text` 块(上限 512 KiB,保留文件名) |
110
+ | `file` 部件为二进制文档(PDF、DOCX 等) | 400,未实现文本抽取 |
111
+ | `file` 部件用 `file_id` | 400,未实现 Files API |
112
+
113
+ 附件存放在 DSH 的附件库里,所以镜像会话只保留一个紧凑引用,而不是几 MB 的 base64。
114
+
115
+ ### 限制
116
+
117
+ | 限制 | 值 | 来源 |
118
+ |---|---|---|
119
+ | 请求体 | 8 MiB | 插件默认值 `limits.maxBodyBytes` |
120
+ | 单个内联文本文件 | 1 MiB,超出会截断并标注 | 插件默认值 `limits.maxTextFileBytes` |
121
+ | 单张图片 | 20 MiB | DSH 附件服务(不是本插件) |
122
+ | 每条消息图片数 | 20 | DSH 附件服务 |
123
+ | 每条消息图片总字节 | 200 MiB | DSH 附件服务 |
124
+ | 图片像素 / 最长边 | 64 MP / 8192 px | DSH 附件服务 |
125
+
126
+ 文本文件这个上限是**提示词体积保护**,不是协议限制:整个请求体已经被 `maxBodyBytes` 卡住,
127
+ 而接近这个上限的文件早就超出多数模型的上下文窗口了。截断会在内联文本里明确写出来,模型知道
128
+ 自己拿到的是片段。
129
+
130
+ **故意忽略**:其余未知字段(`top_p`、`seed`、`service_tier`、`metadata` …)。真实客户端
131
+ 会发一堆本版本没有建模的字段,严格拒绝会破坏更多东西,所以记录日志后丢弃,而不是假装生效。
132
+
133
+ ## 会话身份
134
+
135
+ 解析顺序,先命中先用:
136
+
137
+ 1. `x-dsh-session-id`(本插件自己的头)
138
+ 2. 已知 agent 会话头:`x-session-id`、`x-conversation-id`、`x-chat-id`、`x-thread-id`、
139
+ `x-agent-session-id`、`x-claude-code-session-id`、`x-codex-session-id`
140
+ 3. body 字段:`session_id`、`conversation_id`、`chat_id`、`thread_id`、
141
+ `metadata.session_id`、`metadata.conversation_id`、`user`
142
+ 4. 内容指纹:`sha256(model + 首条 user 消息)`——跨请求、跨重启都是确定值
143
+ 5. 只有在完全没有 user 消息时,才新生成
144
+
145
+ `x-request-id`、`x-correlation-id`、`x-trace-id` 永远不会被当成会话 ID。
146
+
147
+ 每个响应都回写 `x-dsh-session-id`。客户端回传即可锁定这段对话;什么都不发的客户端也能靠
148
+ 第 4 条拿到稳定 ID。
149
+
150
+ 镜像只追加新的 user / tool 消息(assistant 消息永远由本端点自己的流产生),所以日志按轮次
151
+ 线性增长,不会二次膨胀。如果客户端的 user 历史与记录的前缀不一致,会新开一条 DSH 会话并记日志。
152
+
153
+ ## 支持的客户端
154
+
155
+ 任何能把 OpenAI 兼容 provider 指向自定义 base URL 的客户端:
156
+
157
+ ```bash
158
+ export OPENAI_BASE_URL=http://127.0.0.1:43130/v1
159
+ export OPENAI_API_KEY=anything # 没有鉴权,随便填占位符
160
+ ```
161
+
162
+ | 客户端 | 配置方式 | 状态 |
163
+ |---|---|---|
164
+ | `curl` / 任意 HTTP 客户端 | 直接 POST | 已验证 |
165
+ | OpenAI Python SDK | `OpenAI(base_url=..., api_key="any")` | 请求形态已验证 |
166
+ | OpenAI Node SDK | `new OpenAI({ baseURL: ..., apiKey: "any" })` | 请求形态已验证 |
167
+ | Cline(VS Code) | Provider: OpenAI Compatible → Base URL + 任意 key | 理论上可用,未端到端验证 |
168
+ | Roo Code(VS Code) | Provider: OpenAI Compatible | 理论上可用,未端到端验证 |
169
+ | Continue(VS Code / JetBrains) | `provider: openai`、`apiBase: http://127.0.0.1:43130/v1` | 理论上可用,未端到端验证 |
170
+ | OpenCode(CLI) | 在 `opencode.json` 里加自定义 provider,见下 | **已验证**,含工具循环 |
171
+ | Cherry Studio | 添加供应商 → OpenAI → API Host | 理论上可用 |
172
+ | Chatbox | 模型供应商 → OpenAI API → API Host | 理论上可用 |
173
+ | LobeChat | OpenAI provider → 代理/base URL | 理论上可用 |
174
+ | Open WebUI | 连接 → OpenAI → Base URL | 理论上可用 |
175
+
176
+ ### OpenCode
177
+
178
+ 模型 id 里带斜杠,会破坏客户端的 `provider/model` 解析,所以先在设置页加一个无斜杠别名
179
+ (模型别名),例如 `{"flash": "opencode-go/deepseek-v4.1-flash"}`,再在项目里放一个
180
+ `opencode.json`:
181
+
182
+ ```json
183
+ {
184
+ "provider": {
185
+ "dsh": {
186
+ "name": "DSH Endpoint",
187
+ "npm": "@ai-sdk/openai-compatible",
188
+ "options": { "baseURL": "http://127.0.0.1:43130/v1", "apiKey": "anything" },
189
+ "models": { "flash": { "name": "DeepSeek via DSH" } }
190
+ }
191
+ }
192
+ }
193
+ ```
194
+
195
+ ```sh
196
+ opencode run -m dsh/flash "列出当前目录的文件,告诉我哪个最大"
197
+ ```
198
+
199
+ 可直接复制的文件在 [`examples/opencode.json`](examples/opencode.json)。
200
+
201
+ OpenCode 会发 `x-session-id`,端点本来就把它当会话键,所以整个多步工具循环会落进同一条镜像会话。
202
+
203
+ 工具调用双向透传(进来 `tools`,出去 `tool_calls`);OpenCode 的 shell/read 工具循环已端到端验证。
204
+
205
+ ## 限制
206
+
207
+ - 没有鉴权、没有限流。停止开关就是停用插件。请保持在 `127.0.0.1`;绑 `0.0.0.0`
208
+ 意味着同网段任何人都能花你的额度。
209
+ - 只支持内联图片与文本文件;远程媒体 URL 和二进制文档会被拒绝。
210
+ - `n > 1`、`tool_choice` 非 `auto`、`logprobs`、`top_p`、`seed` 都不会生效。
211
+ - 镜像会话按设计不能在 GUI 里继续对话。
212
+ - 绑定 DSH 内部包(`0.2.1-rc.x`),那边接口变化可能导致本插件失效。
213
+
214
+ ## 开发
215
+
216
+ ```sh
217
+ pnpm install
218
+ node --test # 47 个用例,不需要 DSH
219
+ node spike/mock-provider.mjs 44001 # 本地假 provider
220
+ ```
221
+
222
+ `spike/` 里还有 P0 验证脚本:在临时 `DSH_HOME` 里创建会话、落盘、恢复,并验证 GUI 接管被拒
223
+ (`spike/run.sh`)。
224
+
225
+ ## 已验证 / 未验证
226
+
227
+ 在真实 Desktop 安装上验证过:
228
+
229
+ - `/v1/models`(`opencode-go`、`deepseek-official`、`deepseek-account` 共 31 个模型)
230
+ - 非流式与流式对话,含 usage
231
+ - 同一对话两次请求 → 同一个 `x-dsh-session-id`
232
+ - 镜像会话落盘为 `turn/start → step/start → user → assistant → step/end → turn/end`,
233
+ 第二轮只追加新的 user 消息
234
+ - 镜像会话出现在侧栏 workspace
235
+ - 持有写句柄时 `agentLoop.resume` 被拒
236
+ - 设置页:分区已注册、配置读写往返
237
+ - 在线重绑监听地址(`43130 → 43131 → 43130`),不用重启应用
238
+ - 内联图片(1152×883 截图被正确描述)
239
+ - OpenCode 的完整工具循环(一轮两个 shell 调用,连工具结果一起镜像)
240
+
241
+ 尚未验证:
242
+
243
+ - 其它 IDE agent(Cline、Roo Code、Continue)
244
+ - 同一 provider 额度下的多会话并发
package/SECURITY.md ADDED
@@ -0,0 +1,35 @@
1
+ # Security
2
+
3
+ ## Reporting a vulnerability
4
+
5
+ Please do not open a public issue for security problems. Use GitHub's
6
+ [private vulnerability reporting](https://github.com/wangzhongyang/dsh-endpoint/security/advisories/new)
7
+ on this repository. Include the version, your platform, and reproduction steps;
8
+ you will get an acknowledgement within a few days.
9
+
10
+ ## Threat model
11
+
12
+ `dsh-endpoint` is a **local development tool**. Its security posture is deliberate
13
+ and documented rather than accidental:
14
+
15
+ - **No authentication, no rate limiting.** Anyone who can reach the listening port
16
+ can spend the model quota configured in your harness. This is why the default
17
+ bind address is `127.0.0.1` and why the plugin logs a warning when you switch to
18
+ `0.0.0.0`.
19
+ - **No provider credentials.** The plugin never reads or stores API keys. Every
20
+ call goes through `ctx.llm`, so credentials stay in DSH's credential store and
21
+ are resolved per request by the provider adapter.
22
+ - **Loopback-only management routes.** The settings page talks to
23
+ `/dsh-endpoint/state` and `/dsh-endpoint/config` on the GUI server, and both
24
+ routes reject non-loopback peers.
25
+ - **Remote media URLs are refused.** Only inline `data:` URLs are accepted, so the
26
+ endpoint cannot be used to make the host fetch arbitrary internal addresses
27
+ (SSRF).
28
+
29
+ If you need to expose the endpoint beyond your machine, put it behind a reverse
30
+ proxy that terminates TLS and enforces authentication, and keep the plugin's own
31
+ listener bound to `127.0.0.1`.
32
+
33
+ ## Supported versions
34
+
35
+ Only the latest released version receives fixes.
@@ -0,0 +1,9 @@
1
+ # Bundle layer for dsh-endpoint.
2
+ #
3
+ # This package is a *bundle*: list it in a profile's `dsh.profile.bundles` and
4
+ # this patch contributes the host plugin that serves the OpenAI-compatible
5
+ # endpoint. Every model call is executed by `ctx.llm`, so provider routing and
6
+ # credentials stay owned by whichever profile mounted the adapters.
7
+ - insert:
8
+ - id: dsh-endpoint
9
+ name: dsh-endpoint
package/docs/curl.png ADDED
Binary file
Binary file
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "https://opencode.ai/config.json",
3
+ "provider": {
4
+ "dsh": {
5
+ "name": "DSH Endpoint",
6
+ "npm": "@ai-sdk/openai-compatible",
7
+ "options": {
8
+ "baseURL": "http://127.0.0.1:43130/v1",
9
+ "apiKey": "anything"
10
+ },
11
+ "models": {
12
+ "flash": {
13
+ "name": "DeepSeek V4.1 Flash (via DSH)"
14
+ }
15
+ }
16
+ }
17
+ }
18
+ }
package/icon.svg ADDED
@@ -0,0 +1,13 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="dsh-endpoint">
2
+ <defs>
3
+ <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#5b9dff"/>
5
+ <stop offset="1" stop-color="#2f6fe4"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect width="128" height="128" rx="28" fill="url(#g)"/>
9
+ <path d="M40 52h26a10 10 0 0 1 10 10v14" fill="none" stroke="#fff" stroke-width="8" stroke-linecap="round"/>
10
+ <path d="M88 76H62a10 10 0 0 1-10-10V52" fill="none" stroke="#fff" stroke-width="8" stroke-linecap="round" opacity=".55"/>
11
+ <circle cx="88" cy="76" r="7" fill="#fff"/>
12
+ <circle cx="40" cy="52" r="7" fill="#fff"/>
13
+ </svg>