dsh-openai-api
OpenAI-compatible /v1/chat/completions (streaming and non-streaming) and /v1/models endpoint on the DSH web server.
- Stars
- 0
- Language
- TypeScript
- Created
- Aug 24, 2026
- Updated
- Aug 24, 2026
Introduction
dsh-plugin-openai-api

An OpenAI-compatible endpoint on a running dsh web server:
GET /v1/models
POST /v1/chat/completions (non-streaming and SSE streaming)
Base URL of a default local install: http://127.0.0.1:3080/v1 (the same
port the web GUI serves on). Each request is handled by a real DSH agent
session — its tools and full conversation state — not a stateless model
call. Sessions are persistent, one per (API key, X-Agent-Preset header)
pair: the key identifies the caller, the header picks the agent preset,
and the same pair always resumes the same conversation.
Host-plane plugin: it mounts on the web server at startup; installing it never requires rebuilding the frontend.
Installation
You need a running dsh web with a profile that includes the web-app
bundle (the one that owns the port).
1. Install the plugin. One command links it into your profile and
registers it as a profile bundle (the package declares dsh.bundle.patch,
and the CLI reconciles the profile's bundle list against installed state):
dsh plugin --profile web add /path/to/openai-api
From a DSH source checkout, the same command runs as
pnpm dsh plugin --profile web add … (the checkout's dsh script).
A plain path links the source rather than copying it, so edits to the
plugin are picked up on the next restart — no reinstalling.
Fresh checkouts only: recreate the one machine-local link the plugin's
TypeScript resolves the DSH packages through —
ln -s /path/to/deepseek-harness/apps/cli/node_modules node_modules
inside this repo.
2. Restart dsh web. The install is inert so far: the bundle row
ships disabled: true, so the port still serves only the GUI.
3. Enable and configure the endpoint — append the config block below
(Configuration). Restart dsh web again, and the endpoint is live.
To uninstall later — dsh plugin --profile web remove dsh-plugin-openai-api unlinks the package and removes the bundle row.
Also remove your config block and restart dsh web. Sessions the plugin
created are disposed with it; the web GUI's own sessions are untouched.
Configuration
The configuration is one openai-api row in your profile's patch layer
(~/.dsh/profiles/web/cordis.patch.yml, append to your existing file):
- id: openai-api
disabled: false
config:
model: default
apiKeys:
- local-key
Two things to know about the row:
- the
configblock replaces the plugin's defaults wholesale — list every key you want; nothing is required, and unknown keys are a load error; - to switch the endpoint off again, set
disabled: trueon the row (or remove it) and restart.
The configuration keys
All four are optional.
| key | default | what it does |
|---|---|---|
model | default | The model id the endpoint advertises at /v1/models and echoes in completions. It is the wire identity — what SDKs see — not a DSH model selector: a request that omits model (or names the wire id) runs the host's effective default model selection — the same selection GUI sessions get, from the agent-default-model service, which honors the user's stored model choice. Any other value in a request is a real model id, passed through to the session. (The deployment persona renders {{model}} from the session's model, so every session always carries one.) |
provider | host default | The provider route the created sessions use (the AgentOptions.provider meaning — the host's provider routing). Omitting it is a no-op on single-provider setups. |
apiKeys | unset (no auth) | Client keys, a non-empty array of non-empty strings. When set, a request must present exactly one of them (Authorization: Bearer <key>) — any other key (or no key) is a 401. A key only proves the caller's identity — it does not pick or restrict the preset (that is the X-Agent-Preset header's job) — but it names the caller's session tenant: a session is one (key, X-Agent-Preset) pair, so different presets under one key get different sessions, different keys never share a session even for the same preset, and a client reconnecting with its key resumes its own session. When unset the endpoint is unauthenticated: anyone who can reach the port can prompt the agent (which has the same tool access as the GUI). Keep the web server on its loopback bind (host: 127.0.0.1) unless you set keys — and consider a reverse proxy — before exposing the port. |
cwd | the dsh web process's own working directory | Absolute working directory for the sessions the plugin creates (they run real file and shell tools, so they have a working directory — and the deployment persona in the system prompt renders it, so every session always carries one). Must start with /. |
Using the API
The contract is OpenAI's, with one DSH-specific header
(X-Agent-Preset), one honest limitation (only committed assistant text
crosses the wire), and one legacy field: the body's session field
(previously shared / per_request) is accepted and ignored — every
request lands in the shared session for its (key, preset).
A request
import OpenAI from 'openai'
const client = new OpenAI({
baseURL: 'http://127.0.0.1:3080/v1',
apiKey: 'local-key', // one of the config's apiKeys; 'unused' if you set none
})
// The agent preset rides a header (the body's `user` field is accepted
// but ignored); omit the header to join the default preset.
const preset = { 'X-Agent-Preset': 'webtroll' }
const reply = await client.chat.completions.create(
{
model: 'default',
messages: [
{ role: 'user', content: 'What are you? ' },
{ role: 'assistant', content: "I'm a DSH agent." },
{ role: 'user', content: 'Write a haiku about that.' },
],
},
{ headers: preset },
)
console.log(reply.choices[0].message.content)
// streaming, same session (the same header value joins the same session),
// with the trailing usage chunk
const stream = await client.chat.completions.create(
{
model: 'default',
stream: true,
stream_options: { include_usage: true },
messages: [{ role: 'user', content: 'Count to five, slowly.' }],
},
{ headers: preset },
)
for await (const chunk of stream) {
if (chunk.choices[0]?.delta?.content) process.stdout.write(chunk.choices[0].delta.content)
}
The same, without an SDK (the Bearer header only when you set apiKeys):
curl -s http://127.0.0.1:3080/v1/chat/completions \
-H 'content-type: application/json' -H 'Authorization: Bearer local-key' \
-H 'X-Agent-Preset: webtroll' \
-d '{"messages":[{"role":"user","content":"hello"}]}'
Request parameters
| field | required | what it does |
|---|---|---|
X-Agent-Preset (header) | no | The agent preset id the session joins: anything in ~/.dsh/.agent-presets/, plus the shipped presets. Absent → the profile default preset (the web bundle's standard). Unknown id → 400 listing the roster; a preset discovery reports broken → 400 with the reason. Sent more than once (a comma-joined value) → 400. The preset is resolved at session creation only: a session keeps the preset its first request chose — a different header value always means a different session (and therefore its own preset), within the key's tenant (a different key is always a different session). |
messages | yes | Non-empty array. Roles: user, assistant, system (and developer, mapped to system). Content: a string, or an array of {"type":"text","text":…} parts. First request for a session: the full array is rendered into one labeled transcript that seeds the new session. Later requests: only the latest non-empty user turn is admitted — the session already holds the earlier history, and a follow-up with no user turn is a 400. |
session | no | Accepted, ignored (a legacy "shared" / "per_request" value). Every request lands in the one DSH session for its (key, X-Agent-Preset) pair; requests without the header share one default-preset session per key (with no key configured, all clients share it). |
user | no | Accepted, ignored. It is a standard OpenAI end-user-identifier field (for your own abuse tracking), not a selector — preset selection moved to the X-Agent-Preset header because most clients cannot set this field. |
model | no | Session-creation detail: the first request's value for a (key, preset) session sets the session's agent options; later requests' values are ignored on purpose. Absent (or equal to the configured wire id) → the host's default model selection; any other id is used as the session's real model on the configured (or host default) provider. |
max_tokens / max_completion_tokens | no | Positive integer; same creation-time semantics as model. |
stream | no | Boolean. true → Server-Sent Events: chat.completion.chunk frames, then data: [DONE]. |
stream_options.include_usage | no | Boolean. When true, a trailing usage chunk (choices: []) precedes [DONE]. |
n | no | Must be 1 or absent. |
Rejected with a 400 (the OpenAI error envelope): tools, tool_choice,
functions, function_call (function calling is not supported — the
agent still uses its own tools server-side, they just don't appear on
the wire), tool-role messages, image or other non-text content parts,
n ≠ 1, and unknown stream_options keys.
Responses
- Only committed assistant text leaves the wire — each committed message's text blocks, in order. Reasoning and tool calls stay off it.
- Streaming granularity is per committed message, not per token — one
chat.completion.chunkper committed assistant message, then a finish chunk, then[DONE]. finish_reason: a turn endedmax-tokens→length; everything else that settles (completed,aborted,interrupted,blocked) →stop. Error endings never produce a finish — the request gets a 5xx.usage: DSH token counts are disjoint, soprompt_tokens= input + cache-read + cache-write,completion_tokens= output + reasoning.- Errors use the OpenAI envelope:
{"error": {"message", "type", "param": null, "code": null}}— a 400 for wire violations, 401 for a missing/wrong bearer key, 409 when a second request arrives for a (key, preset) session that is still in flight (there is exactly one prompt per session at a time; different keys — and different presets — are different sessions), and 5xx when the turn itself fails. - Disconnecting mid-request cancels the in-flight turn. A non-stream
request answers 500; a stream simply ends without
[DONE]so SDKs raise instead of silently truncating. The session itself survives — the next request with the same key (and preset) resumes it.
GET /v1/models returns the single configured model id
({"object":"list","data":[{"id": …}]}).
Development
# tests (37: wire unit tests + a fake-ctx integration smoke)
node --import ./test/register.mjs test/test.mjs
# typecheck (repo toolchain, strict)
TSC=/src/misc/harness/deepseek-harness/node_modules/.bin/tsc
"$TSC" --noEmit --strict --noUnusedLocals --noUnusedParameters \
--noFallthroughCasesInSwitch --module nodenext --target es2023 \
--allowImportingTsExtensions --skipLibCheck \
--typeRoots /src/misc/harness/deepseek-harness/node_modules/@types --types node \
openai-api.ts
Layout: openai-api.ts (entry: plugin surface, session bookkeeping, HTTP
routes, settlement), src/wire.ts (pure wire layer: parsing, transcript
rendering, framing, usage/finish mapping), src/config.ts (hand-rolled
Standard-Schema v1 config validator). No runtime dependencies beyond the DSH
packages resolvable from the CLI's node_modules.