dsh-plugin-openai-api
dsh-plugin-openai-api banner
An OpenAI-compatible endpoint on your own DeepSeek Harness (dsh) web
server. Point any OpenAI SDK client at the running dsh web instance and
it drives a real DSH agent session — with all the tools and presets
that agent has:
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.
What the endpoint gives you:
- a real agent on the other end — every request lands on a DSH agent
session, not a thin LLM proxy: tool calls, reasoning, and the full
session machinery happen server-side, and only committed assistant text
comes back over the wire;
- agent presets as clients — the request's
user field names the
agent preset the session joins (standard, or anything in your
~/.dsh/.agent-presets/); one preset = one session, by design;
- two session lifetimes —
shared (the default: one session per
user, conversation state survives between requests) or per_request
(a fresh session seeded from the full message array on every prompt);
- standard OpenAI surface — streaming with usage, the standard error
envelope,
chatcmpl-… ids, and the finish_reason mapping, so SDK
code you already have works unchanged.
The plugin is host-plane: it mounts on the web server at startup, and
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).
With the dsh command
1. Install the plugin. One command does both halves — it links the
plugin into your profile and registers the mounting row for it:
dsh plugin --profile web add /path/to/openai-api
(Use a plain path, not a file: URL — a plain path links the source, so
edits are picked up without reinstalling. Without the dsh binary on your
PATH, run node --import tsx/esm apps/cli/src/bin.ts plugin … from inside
the DSH checkout.)
If you checked this repo out fresh, recreate the one machine-local link the
source needs first: ln -s /path/to/deepseek-harness/apps/cli/node_modules node_modules
inside this repo.
2. Restart dsh web once.
3. Enable and configure the endpoint — one small block in your own
config, below. Then restart dsh web again, and the endpoint is live.
The install ships switched off on purpose: until you add that block,
the port serves only the GUI. What you enable, with what key, is your call.
From source (no dsh command)
The command above does exactly these three things, by hand:
-
Link the package into the profile — in
~/.dsh/profiles/web/package.json, add the dependency (alongside the
existing ones) and the bundle (into the existing list):
{
"dependencies": {
"dsh-plugin-openai-api": "link:/home/noname/deepseek-harness/dsh-plugins/openai-api"
},
"dsh": {
"profile": {
"bundles": [
"…",
"dsh-plugin-openai-api"
]
}
}
}
-
Recreate the machine-local source link (fresh checkouts only):
ln -s /path/to/deepseek-harness/apps/cli/node_modules node_modules
inside this repo — the plugin's TypeScript resolves the DSH packages
through it.
-
Enable the row in the profile patch layer (next section).
Then restart dsh web.
To uninstall later — command path: dsh plugin --profile web remove dsh-plugin-openai-api. Source path: remove the two lines above from the
profile's package.json. Either way, 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
apiKey: local-key
Two things to know about the row:
- the
config block 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: true on 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. |
apiKey | unset (no auth) | When set, every request must carry Authorization: Bearer <apiKey> — missing or wrong key is a 401. 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 this — 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 two DSH-specific fields (user, session)
and one honest limitation: only committed assistant text crosses the wire.
A request
import OpenAI from 'openai'
const client = new OpenAI({
baseURL: 'http://127.0.0.1:3080/v1',
apiKey: 'local-key', // the config's apiKey; 'unused' if you set none
})
const reply = await client.chat.completions.create({
model: 'default',
user: 'webtroll', // agent preset id; omit for the default preset
messages: [
{ role: 'user', content: 'What are you? ' },
{ role: 'assistant', content: "I'm a DSH agent." },
{ role: 'user', content: 'Write a haiku about that.' },
],
})
console.log(reply.choices[0].message.content)
// streaming, same session (`user: 'webtroll'` matches the first request's
// session), with the trailing usage chunk
const stream = await client.chat.completions.create({
model: 'default',
user: 'webtroll',
stream: true,
stream_options: { include_usage: true },
messages: [{ role: 'user', content: 'Count to five, slowly.' }],
})
for await (const chunk of stream) {
if (chunk.choices[0]?.delta?.content) process.stdout.write(chunk.choices[0].delta.content)
}
// a fresh session for every prompt, seeded from the full message array
const once = await client.chat.completions.create({
model: 'default',
session: 'per_request',
messages: [
{ role: 'system', content: 'You answer in exactly one sentence.' },
{ role: 'user', content: 'What are you?' },
],
})
The same, without an SDK (the Bearer header only when you set apiKey):
curl -s http://127.0.0.1:3080/v1/chat/completions \
-H 'content-type: application/json' -H 'Authorization: Bearer local-key' \
-d '{"user":"webtroll","messages":[{"role":"user","content":"hello"}]}'
Request parameters
| field | required | what it does |
|---|
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 (shared mode, or every per_request): the full array is rendered into one labeled transcript that seeds the new session. Later shared-mode request: 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. |
user | 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. The preset is resolved at session creation only: in shared mode a session keeps the preset its first request chose — a different user value always means a different session (and therefore its own preset). |
session | no | "shared" (the default — omit it): one DSH session per user value; requests without user share one default-preset session. "per_request": a fresh session for every prompt, seeded from the full messages array each time. |
model | no | Session-creation detail. Shared mode: the first request's value sets the session's agent options; later requests' values are ignored on purpose. per_request: applies to that request's session. 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 . |
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.chunk per committed assistant message, then a finish
chunk, then [DONE].
finish_reason: a turn ended max-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, so prompt_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 shared key that is still in flight (there
is exactly one prompt per key at a time; per_request has no 409s), 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.
per_request sessions are kept alive after the response — never
reused, disposed only at plugin teardown.
GET /v1/models returns the single configured model id
({"object":"list","data":[{"id": …}]}).
Development
# tests (32: 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.