dsh-llm-session-header
Verifieddsh-llm-session-header Β· v0.4.0 Β· MIT Β· Web UI
DeepSeek Harness plugin: attaches a stable per-conversation session header to model requests routed to configured providers. Header name is configurable, so one plugin serves SMG (X-SMG-Routing-Key), OpenCode (x-opencode-session), and any other header-bas
Install
dsh plugin add dsh-llm-session-header Confirm the layer applied with dsh --profile default --dump-config β see the install guide.
Source
Tags
Readme
dsh-llm-session-header
A DeepSeek Harness host plugin that attaches a stable per-conversation session header to model requests routed to configured providers.
The header name is configurable, so one plugin serves any backend that
implements conversation-affinity routing via a request header β SMG's
X-SMG-Routing-Key, OpenCode's x-opencode-session, and others.
| Verified against | dsh 0.1.7-rc.2 and 0.2.0-rc.2 (session format 4) |
| Requires | Node ^22.19 || >=24; schemastery >=3.18.4 for the settings card |
| On older dsh | the plugin loads and injects as usual; the settings card is simply absent |
Nothing needed to change for 0.2.0. The llm/stream contract, the settings service, the
settings API controller and the client settings layer are all byte-identical between
0.1.7-rc.2 and 0.2.0-rc.2; only the client primitives and plugin-manager bundles moved, and the
card renders correctly on the new ones. Verified in an isolated 0.2.0 instance: the card
renders every control, a save persists to the profile patch, and a route added through the
card after activation injects its header on the next model call (checked on the wire at a
header-recording mock).
npm test runs tests/compat-0.2.mjs, which pins the schema and the live-save path against a
0.2.0 install when one is pointed at: DSH_020=<prefix> npm run test:0.2.
Upgrading on a pnpm-managed profile. dsh profiles installed with pnpm enforce a supply-chain minimum-release-age policy, so a version published minutes ago is refused until it is listed in
minimumReleaseAgeExcludein~/.dsh/profiles/<profile>/pnpm-workspace.yaml. Keep the old entry listed while you upgrade β the policy checks the versions pinned inpnpm-lock.yaml, so replacing it too early fails on the version you are leaving. Then restart the service that reads the profile.
Why
Some LLM relays pin every request sharing the same session header value to the
same upstream backend, which keeps the prompt/KV cache warm across the turns of
one conversation. Without the header those relays either reject the request
(OpenCode returns 400 MissingSessionID) or route each turn to an arbitrary
backend, discarding the cache every turn.
The value only has to be opaque and stable per conversation, so the plugin
reuses the DSH session id that already travels with each model call β the same
identity the official DeepSeek adapter sends as x-deepseek-harness-session-id.
Why the header is injected rather than derived
A relay's alternative to an explicit key is deriving stickiness from a prefix hash of the prompt. That approach does not work for DSH, and it was verified experimentally rather than assumed: DSH sends a large shared system prompt, so distinct sessions hash to the same value and pin to the same backend. Extending the hashed prefix cannot fix it, because the shared preamble is the hash input.
The plugin is therefore not an optimisation on top of a working router β it is what makes per-conversation pinning work at all.
Install
dsh plugin --profile <profile> add dsh-llm-session-header
Or from a local checkout:
dsh plugin --profile <profile> add /abs/path/to/dsh-llm-session-header
The package declares dsh.bundle, so dsh plugin add reconciles it into
dsh.profile.bundles automatically and it becomes a profile layer. Restart
dsh once for the change to take effect.
On boot you should see:
[llm-session-header] active for providers [opencode=x-opencode-session] with mode session-id
Configuration
The row above is inserted by this package's own bundle layer with the defaults
shown. A profile customises it with an id-targeted config override, never
a second insert:
- id: llm-session-header
config:
headerName: x-opencode-session # default
providers: [opencode, opencode-go]
mode: session-id
debug: false
| Field | Default | Meaning |
|---|---|---|
headerName |
x-opencode-session |
Header to attach when providers is a list |
providers |
[opencode, opencode-go] |
Which route keys get a header, and which header |
mode |
session-id |
session-id reuses the DSH session id; uuid derives a process-stable uuid |
debug |
false |
Log every streamed call that receives a header |
debugFile |
β | Append one JSON line per injected call to this path |
providers accepts two shapes
A list β every listed route gets headerName:
config:
headerName: X-SMG-Routing-Key
providers: [b70-smg]
A map β each route names its own header, overriding headerName:
config:
providers:
opencode: x-opencode-session
opencode-go: x-opencode-session
b70-smg: X-SMG-Routing-Key
A list and a map cannot be mixed. The map form is how one plugin instance serves multiple backends with different header names. It is also what makes swapping the router behind a route a one-line config change:
- b70-olla: X-Olla-Session-ID
+ b70-smg: X-SMG-Routing-Key
Editing from the Web UI
The plugin ships a settings card, so the whole mapping is editable from the Plugins page without touching YAML:
Plugins β Installed β LLM session header β the llm-session-header
component β Configure.
The card edits:
- the default header name, the value mode and the debug flag;
- the route table itself β Add route appends a row, each row names a route key and (optionally) its own header, and Remove drops one.
Each route is picked from a dropdown of the routes the deployment actually
composes, so a typo cannot silently produce a route that never matches. The list
is read from the composed configuration (the llm-pi-ai providers, the
agent-default-model route, and the llm-* namespaces), not hardcoded. A route
already in your config but not in that list is kept as its own option, and
+ Custom route⦠switches a row to a free-text field for anything the list
does not know about.
The plugin's display name is localised.
locale/en.jsonandlocale/zh.jsoncarry the title and description the Plugins page shows, so the bundle appears as "LLM session header" / "LLM δΌθ―θ·―η±θ―·ζ±ε€΄" rather than as the package name. Anything keying on the package name in the UI β a script, a bookmarklet, a test selector β must use the localised title instead.
Why it lives on the row, not in the Official list. The page's slot contract marks
plugins.itemas "OCCUPIED by the official settings pages, one companion package per host-plane namespace; a bundle's configuration belongs inplugins.bundle.configorplugins.row.configinstead". A third-party bundle therefore registers intoplugins.row.config, keyeddsh-llm-session-header#llm-session-header, which is what puts a Configure button on the row. Our settings namespace is the row id, so the row is the right owner. (Verified on both dsh 0.1.7-rc.2 and 0.2.0-rc.2, whose slot contracts are byte-identical.)
Leave a row's header blank to inherit the default header name. Saving writes the
whole providers field in one fenced revision-checked mutation, so a concurrent
edit elsewhere in the profile is rejected rather than silently overwritten.
Saves take effect on the next model call β no restart. Every field is
declared .volatile(), so the Host rewrites the live config reference in place
instead of remounting the plugin; the route table is re-resolved on each
llm/stream call.
Serving OpenCode and SMG together
The map form is what the production profile actually uses β one row covering every route, each with the header name its own backend expects:
- id: llm-session-header
config:
headerName: x-opencode-session
providers:
opencode: x-opencode-session
opencode-go: x-opencode-session
opencode-go-v41: x-opencode-session
b70-smg: X-SMG-Routing-Key
mode: session-id
debug: false
Note: the
configof a patch row replaces wholesale β it does not merge. Restate every key you want to keep,headerNameincluded.
Do not
insertthis row from a profile patch. The package ships its owndsh.bundlelayer that already inserts thellm-session-headerrow, so a secondinsertat the profile layer produces a duplicate row id and a boot failure. Use the id-targetedconfigform above, exactly as shown.
Verifying the deployed composition
dsh --profile <profile> --dump-config | grep -A11 "id: llm-session-header"
Expect exactly one row, carrying name: dsh-llm-session-header.
How it works
llm/streamwaterfall β calls whoseoptions.provideris a configured route and which carry asessionIdare wrapped.- AsyncLocalStorage β the header name and value are held in an ALS store.
The plugin wraps the downstream iterator so every pull executes inside
als.run(...), which is what makes the value visible to the code that eventually issues the HTTP request. globalThis.fetchpatch β patched once, fiber-scoped viactx.effect. While a store is active it merges<headerName>: <value>into the outgoing request headers. An existing header of that name always wins.
On plugin stop/update/unload the listener is removed and the original fetch
restored.
Requests that are not routed to a configured provider, requests with no
sessionId, and model-discovery requests pass through untouched.
Verifying a live deployment
The header is injected into an outgoing request, so a passing unit test is not proof that a deployed router is receiving it. Check the routing decider, not a counter.
SMG echoes the worker it chose on every response:
x-smg-routed-worker-id: http://<worker-host>:<port>
Send several requests carrying the same routing key and confirm this header always names the same worker. That is stable per-key assignment β the exact property this plugin exists to produce β and it is directly observed rather than inferred.
- Same worker every time β stickiness works β the header was received.
- Worker varies across requests β the key is not driving assignment.
Do not verify with
GET <smg>/workers'loadfield. It is a single field that does not distinguish "assigned routing keys" from "in-flight requests", and on current builds it renders the latter β so it reads0whenever the gateway is idle, including while sticky routing is working correctly. Relying on it produces both false negatives and (as happened here once) false positives. See docs/smg-load-field-correction.md for the measurement and the reasoning.
Notes and limitations
- Provider keys are matched case-sensitively against
options.provider. A custom route key must be listed explicitly β it is not inferred. - The model-listing flow (
GET <baseURL>/models) does not receive the header. It is a separate code path and is not session-scoped. - The plugin relies on DSH outbound LLM requests going through Node's global
fetch. If a future dsh version changes its network stack, injection stops (symptom: the upstream rejection returns). - Only one
llm/streamlistener is registered per plugin instance. Do not mount two rows of this plugin with overlapping provider sets β the outer wrapper would win and inner configuration would be ignored. Use one row with the map form instead.
Development
# Run full test suite
npm test
# Build client bundle from src/client/
npm run build:client
| Layer | File | What it proves |
|---|---|---|
| Unit | tests/test.mjs |
Config resolution, value modes, fetch injection, stream wrapping, BoundedMap, and schema/locale metadata |
| Config updates | tests/config-update.test.mjs |
A live save reaches a running instance through volatile references |
| Integration | tests/integration.mjs |
Real fetch patch + real HTTP to a local mock; per-route headers |
| Slot contract | tests/slot-contract.mjs |
Verifies plugins.row.config registration and contract compliance |
| Coexistence | tests/coexistence.mjs |
Byte-parity with the original, and both mounted together |
Client Bundle Architecture (src/client/)
The client settings card is modularized under src/client/:
constants.js: Slot keys (PACKAGE,ENTRY_ID), modes, default headers, and styles.locales.js: Localization dictionaries (en,zh).store.js: Minimal zustand-compatible state store (createLocalStore).controller.js: Draft management and revision-checked save mutation (CardController).card.js: Presentational React components (LlmSessionHeaderCard).lifecycle.js: Plugin lifecycle hook andplugins.row.configslot registration (apply).
To build the client bundle into lib/client.js, run npm run build:client.
lib/client.jsis a committed build artifact. The browser only ever loads that file, so editingsrc/client/without rebuilding ships a stale card β and nothing fails: the bundle still parses and the tests still pass, because they exercise whatever is committed.tests/client-bundle.test.mjsloads the built bundle through a stub module loader (so a broken concatenation is caught rather than assumed), and CI and the publish workflow both rebuild andgit diff --exit-code lib/client.js, so drift fails the build instead of reaching npm.
Settings Page Row Metadata
Row title and description shown on DSH's Plugins list page are resolved by @deepseek-ai/dsh-app-boot from locale/en.json and locale/zh.json:
- English:
LLM session header - Chinese:
LLM δΌθ―θ·―η±θ―·ζ±ε€΄
The integration test boots the plugin, patches fetch, drives a real HTTP
request through a local mock, and asserts per-route header routing. It covers
the shipping b70-smg route by name, plus x-opencode-session parity.
To render the settings card in a throwaway instance (never production), see the
workspace skill .dsh/skills/dsh-plugin-settings-card/; it provisions an
isolated DSH_HOME, a prefix with its own dsh, and a mock provider.
Never point a test at production DSH. Use an isolated
DSH_HOMEunder a durable path (not/tmp, which can be swept between commands), and set it in the same shell invocation as the command:ISO=~/dsh-build/iso-home DSH_HOME="$ISO" dsh plugin --profile boot-test add /abs/path/to/this/pluginIf the variable is lost,
DSH_HOMEsilently falls back to~/.dshand the command writes to the production profile. Verify isolation afterwards.
Credits
Derived from dsh-opencode-session by nobu121 (MIT), generalised from a hardcoded header to a configurable one.
License
MIT