Back to home@paulalesius

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
GitHub repo

Introduction

dsh-plugin-openai-api

dsh-plugin-openai-api banner

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 laterdsh 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 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.

keydefaultwhat it does
modeldefaultThe 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.)
providerhost defaultThe 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.
apiKeysunset (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.
cwdthe dsh web process's own working directoryAbsolute 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

fieldrequiredwhat it does
X-Agent-Preset (header)noThe 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).
messagesyesNon-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.
sessionnoAccepted, 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).
usernoAccepted, 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.
modelnoSession-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_tokensnoPositive integer; same creation-time semantics as model.
streamnoBoolean. true → Server-Sent Events: chat.completion.chunk frames, then data: [DONE].
stream_options.include_usagenoBoolean. When true, a trailing usage chunk (choices: []) precedes [DONE].
nnoMust 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.chunk per committed assistant message, then a finish chunk, then [DONE].
  • finish_reason: a turn ended max-tokenslength; 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 (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.