dsh-thinking-effort
English | 简体中文
Bring back the thinking-intensity picker (推理等级 / reasoning effort) for third-party API keys in DeepSeek Harness.
You plug an OpenAI-compatible relay into dsh, pick one of its models, and the
「推理等级」 pane in the composer is simply gone. This plugin declares the missing capability for
those routes, so the picker comes back — from a slash command, from a settings tab, or by asking
the model.
The problem, precisely
A third-party provider is a providers.<route> profile on the llm-pi-ai adapter:
# $DSH_HOME/profiles/<profile>/cordis.patch.yml
- id: llm-pi-ai
name: '@deepseek-ai/dsh-llm-pi-ai'
config:
providers:
qiyuan:
apiKeyEnv: QIYUAN_API_KEY
api: openai-completions
baseURL: https://api.qiyuanapi.cc/v1
models:
- id: deepseek-v4.1
contextWindow: 1000000
Nothing in that flow writes reasoningEfforts, and @deepseek-ai/dsh-llm-pi-ai decides a model's
reasoning capability like this:
const efforts = entry.reasoningEfforts
if (efforts === undefined) return { reasoning: base?.reasoning ?? false }
A hand-declared route has no installed-catalog base, so every one of its models resolves to
reasoning: false. The adapter then advertises no effort levels, the composer's picker has nothing
to render, and a forced effort is refused with UNSUPPORTED_REASONING_EFFORT. That is the whole
symptom.
It is not about the key or the vendor: catalog routes keep their shipped capability, and the same
relay reached through llm-deepseek works, because that adapter advertises efforts for every route
it serves. It is a missing declaration.
What this plugin does
It writes that declaration — in the one place the adapter reads it, through the supported settings seam. No adapter takeover, no HTTP interception, no harness patch. By default it does so automatically at startup: any third-party route with no declaration is filled in, so installing the plugin is the whole setup.
# what it writes, for the route above
providers:
qiyuan:
models:
- id: deepseek-v4.1
contextWindow: 1000000
reasoningEfforts: # ← the declaration the picker reads
off:
low: low
high: high
max: max
compat:
thinkingFormat: deepseek
Four entry points over one shared, unit-tested planner:
| Surface | How |
|---|---|
| Automatic | Nothing to run: a load-time pass declares every third-party route that has none |
| Slash command | /thinking · /thinking presets · /thinking adapt · /thinking set <provider>/<model> <preset> · /thinking remove … |
| Settings tab | Settings → Plugins → 思考强度 — per-model dropdown, applies instantly |
| Model tool | thinking_effort_status / thinking_effort_set, so "这个模型调不了思考强度" gets fixed conversationally |
A load-time diagnostic also names every route that still cannot be adjusted, so a freshly added key is noticed immediately instead of the first time you open the model picker.
Install
# from a checkout
dsh plugin --profile <profile> add /path/to/dsh-thinking-effort
# from GitHub (replace with your fork)
dsh plugin --profile <profile> add github:<you>/dsh-thinking-effort
dsh plugin forwards to pnpm inside the profile directory. A GitHub install pulls sources and
pnpm runs prepare (i.e. tsdown) to build lib/. On pnpm ≥ 10 the first git-dependency build is
refused; add the package name pnpm prints to the profile's pnpm-workspace.yaml and retry:
allowBuilds:
dsh-thinking-effort: true
That allowlist authorizes executing this package's code at install time. Only allow source you trust, and prefer pinning a commit:
github:<you>/dsh-thinking-effort#<sha>.
Then restart dsh — bundle membership is only read at boot — and check the layer is composed:
dsh --profile <profile> --dump-config # expect a row named dsh-thinking-effort
On the Windows desktop build the
desktopprofile is reserved and file-locked by the running app: quit DeepSeek Harness before installing into it.
Usage
Nothing to do. A freshly added third-party key is declared automatically at startup, and the log says which route got which level map:
# dsh-thinking-effort: 自动适配完成。
# ✅ 已自动声明 1 个模型的思考强度档位:
# 路由 qiyuan → passthrough(1 个模型)
# 端点检测为 openai,写入 passthrough
# 档位:off→(不发) minimal→minimal low→low medium→medium high→high xhigh→xhigh max→max
# 这是可以随时改的起点:换档位名或方言用 /thinking set,预览用 /thinking explain。
Then open the model picker in the composer → 推理等级 → Off / Minimal / Low / Medium / High / XHigh / Max. Picking one also saves it as the Agent default, exactly like a built-in provider.
What automatic adaptation decides
It only touches configuration-declared routes — a catalog route already resolves its models' capability from the installed catalog, so a declaration there would only narrow it. For each route it reads the evidence already in the configuration:
- If pi-ai already believes the endpoint does not take
reasoning_effort(xAI, Z.AI, Moonshot, Together, NVIDIA, Cloudflare, or your owncompat.supportsReasoningEffort: false), it writesopenai-null-off. Sendingxhighto an endpoint that does not parse the parameter is the one way this plugin could turn a missing picker into a failing request. Your explicit setting outranks the plugin's inference. - Otherwise, if the endpoint host identifies a vendor (
deepseek.com,openrouter.ai, DashScope, …), it writes that vendor's preset. - Otherwise it writes
autoAdaptPreset,passthroughby default: all seven levels sent under their own names, no dialect pinned. A gateway with its own ladder gets the whole ladder back that way.
It never overwrites a model you already declared, and never touches reasoningEfforts: false — that
is an explicit "this model does not reason", and adaptation must not undo it.
Adaptation supplies the declaration; it cannot verify that the endpoint honours these values,
because that is not discoverable from configuration. After seeing the log line, check what each level
actually sends with /thinking explain, then try a level in a session.
Adjusting it by hand
What it wrote is a starting point you can change at any time:
/thinking
# 路由 qiyuan(自声明路由)
# - deepseek-v4.1: 可调 · 档位 off / minimal / low / medium / high / xhigh / max
/thinking explain qiyuan/deepseek-v4.1
# 路由 qiyuan · deepseek-v4.1
# 当前声明(未锁方言,pi-ai 会按端点检测为 openai)
# 实际生效的方言:openai(由端点检测得出)
# compat.supportsReasoningEffort:true
# 每档实际发送的参数:
# off (不发送任何推理参数)
# low {"reasoning_effort":"low"}
# ...
/thinking set qiyuan/deepseek-v4.1 deepseek-relay
# ✅ 已写入 qiyuan/deepseek-v4.1 的思考强度声明。
/thinking adapt runs the pass again on demand: /thinking adapt qiyuan --preset deepseek-relay
restricts it to one route and names the level map.
To turn adaptation off (this plugin's own profile config):
- id: dsh-thinking-effort
name: 'dsh-thinking-effort'
config:
autoAdapt: false # only write when /thinking adapt is run
autoAdaptPreset: passthrough
Dialect presets
Which wire values the endpoint accepts is provider-specific; reasoningEfforts maps selectable
level → value sent. But the levels are only half of it: each thinkingFormat is a separate
branch that suppresses the generic OpenAI path, so pinning the wrong dialect can make a level do
nothing while appearing to work. That is why the presets are ordered safest first, and why only the
vendors that need one pin a dialect at all.
| Preset | Levels | Sends | Pins | Fits |
|---|---|---|---|---|
openai-null-off |
off / low / medium / high | reasoning_effort; off sends nothing |
no | Any OpenAI-compatible endpoint — try this first |
openai |
off / low / medium / high | reasoning_effort; off → "none" |
no | OpenAI itself, or a relay that accepts none |
deepseek-relay |
off / low / high / max | reasoning_effort |
no | A third-party gateway serving DeepSeek models |
deepseek |
off / low / high / max | reasoning_effort + thinking:{type:…} |
deepseek |
DeepSeek's own endpoint, or a strict clone |
openrouter |
off / low / medium / high | nested reasoning:{effort} |
openrouter |
OpenRouter and mirrors |
qwen |
off / low / high | enable_thinking (+ reasoning_effort) |
qwen |
Qwen family |
passthrough |
all seven | level name verbatim | no | Gateways that know pi-ai's own names |
custom |
yours | yours | — | Anything else — pass levels yourself |
inherit |
unchanged | unchanged | — | Only pin --format / --default |
off: with no value means "supported, but dispatch sends nothing" — the right spelling when not
thinking is the absence of the parameter. Only off may be left empty.
Why deepseek-relay exists. deepseek pins DeepSeek's private thinking:{type:…} parameter,
which an OpenAI-compatible relay generally ignores. Ignoring is worse than failing: with thinking
switched off, that branch sends no reasoning_effort either, so Off does nothing while a named
level looks fine. If Off seems to have no effect on a relay, this is the cause — switch to
deepseek-relay or openai-null-off, both of which send only the standard parameter.
/thinking explain <provider>/<model> [preset] prints, for every level, the exact request
parameters it will send — plus what pi-ai would have detected had nothing been pinned, and a
warning when two levels collapse to the same request. Nothing is written.
Overrides: /thinking set qiyuan/m deepseek --format deepseek --default high.
How it works
settings.update(entryId, { providers: { <route>: … } }) merges, so supplying one route key
leaves every other route — and every field of this route it does not own — untouched. The shape of
the patch depends on where the route's capability comes from, because mergeLayers replaces arrays
wholesale and llm-pi-ai refuses modelOverrides on a route that also carries a models list:
- a route with a non-empty
modelslist (every hand-declared third-party route) → the list is rewritten in full, carrying every entry and every unowned field through; - a route inheriting the installed catalog → a
modelOverridesentry, a dict that merges and never narrows the served catalog.
One side effect is worth knowing before you read the diff. The rewritten models array is built from
the resolved section, so schema defaults the resolver had already materialized ride along into the
stored document — after declaring a level on a hand-declared route, that model entry typically gains
compat.chatTemplateKwargs: {} and compat.chatTemplateArgs: {} beside the thinkingFormat this
plugin actually wrote. They are the schema's own defaults, so the resolved value is unchanged and
nothing behaves differently; the document just becomes explicit about fields it previously left
implicit. The write stays narrow at the route level: only the route you named appears in the user
layer, and no route-level default is materialized.
Writes are revision-fenced against the descriptor read in the same pass (a concurrent editor is reported, then retried once against a fresh read), and a declaration that is already in force writes nothing at all. That is what makes automatic adaptation idempotent: the first boot writes, every later boot is a zero-write no-op, and it needs no memory of what it wrote — an in-force declaration is itself what leaves it with nothing to do.
Adaptation and the diagnostic run at the same injection point, adaptation first, so a log line
saying "every model is adjustable" describes the state the pass just produced rather than one about
to arrive. The settings service can register before the pi-ai adapter does, so a pass that finds no
descriptor retries up to five times at half-second intervals before giving up and pointing at
/thinking adapt.
The settings entry id is taken from the adapter's own configurable-provider directory
(listConfigurableProviders().settingsNs), so a profile that renames the loader entry still works;
llm-pi-ai is only the fallback.
What it deliberately does not do
- No route-level removal.
removestripsreasoningEffortsand thethinkingFormatthis plugin wrote. It leavesreasoning/thinkingBudgetsalone:updatemerges, JSON has noundefinedto merge in, and unsetting fields this plugin does not own would need a path op against somebody else's config. - No HTTP-layer rewriting. There is no supported interceptor for the request body, and mutating the frozen call config throws. Declaring the capability is the supported path.
- No probing of the endpoint. Automatic adaptation judges only the evidence already in the
configuration: it neither spends your API key on test requests nor infers a dialect by watching
which level has an effect. A single request cannot tell "the parameter was ignored" apart from "the
parameter worked", so that conclusion is yours to draw in a session; renaming a level is one
/thinking set.
Development
pnpm install
pnpm typecheck # tsc --noEmit
pnpm build # tsdown → lib/index.js + lib/client.js
pnpm test # node --test over the pure modules and the build output
pnpm verify # all three
pnpm test runs without a dsh installation. test/smoke.test.mjs registers a resolver hook
(test/resolve-hook.mjs) that redirects @deepseek-ai/dsh-tools to a faithful local stub: the real
package resolves a chain of harness-internal peers a plugin repo has no business installing, and
defineTool is an identity helper at runtime — its real signature still type-checks from the
devDependency. The hook is registered inside the test file itself, so pnpm test behaves the same
whether or not the runner forwards its own flags to per-file child processes.
Layout:
src/
index.ts host entry: Config, registrations, load-time adaptation + diagnostic
presets.ts thinking levels, wire dialects, the validator (pure)
plan.ts declaration → settings patch (pure, shared with the browser)
auto.ts which preset an undeclared route gets (pure)
diagnose.ts capability report (pure)
wire.ts what each level actually puts on the wire (pure)
types.ts stored/read shapes, the settings port, dialect constants
json.ts isPlainObject / deepEqual / omit (dependency-free)
apply.ts the settings port: read descriptor, plan, merge the write
commands.ts /thinking, including the `adapt` batch entry
tools.ts thinking_effort_status / thinking_effort_set / thinking_effort_explain
format.ts shared text rendering
client/
index.ts browser half entry: inject / apply
card.ts the settings tab
styles.ts its stylesheet
types.ts structural types for the two browser services used
test/ unit tests + a smoke test over the build output
Working notes for anyone (human or agent) about to change the code — the invariants, the build
constraints, and a verified pnpm verify baseline — live in AGENTS.md
(简体中文).
License
MIT
No comments yet. Be the first to write one.