dsh-autofill-model-metadata
Fill a DSH gateway route's model metadata (name, contextWindow, maxTokens,
input, reasoningEfforts) from the models.dev catalog —
using only the mappings you declare by hand.
This is a port of the pi-autofill-model-metadata Pi extension: the same
explicit-mapping philosophy, moved onto the DSH settings seam.
Why explicit mapping
DSH has no native metadata filling. The plugins that do it fall into two camps:
provider-level refresh (dsh-models-dev: sources: { route: <models.dev-provider> })
and loose name matching. Both guess. This plugin refuses to:
- You write down, per route and per local model id, exactly which models.dev
model it is:
mapping.<route>.<localModelId> = "<modelsDevProvider>/<modelsDevModel>".
- No fuzzy match, no case folding across providers, no searching another
provider when a lookup fails.
- Anything unmapped, unknown, or ambiguous is a refusal, not a guess. A route
whose plan refuses is not written at all — you never get a half-filled array.
Declared fields always win: the plugin only fills fields the profile leaves
undefined.
Install
Requires DSH >= 0.2.0-rc.2. From the plugin checkout:
DSH="/Applications/DeepSeek Harness.app/Contents/Resources/runtime/cli/bin/dsh"
"$DSH" plugin --profile desktop add link:/absolute/path/to/dsh-autofill-model-metadata
Then restart DSH Desktop (plugins mount at startup).
Configure
The config accepts these fields (the graphical page edits the mapping, the
rest stays in the settings document):
{
"mapping": {
// route name as it appears in Settings → Models
"my-gateway": {
// local model id on the wire -> models.dev provider/model
"claude-opus-4-5": "anthropic/claude-opus-4-5",
"gemini-3-pro": "google/gemini-3-pro-preview"
}
},
"modelsDevUrl": "https://models.dev/api.json",
"cacheTTLHours": 24,
"applyOnStart": true,
"dryRun": false,
"requireCompleteMapping": true,
"builtinSettingsForm": "auto"
}
| field | default | meaning |
|---|
mapping | {} | the declared table; empty means the plugin does nothing at all |
modelsDevUrl | https://models.dev/api.json | catalog endpoint |
cachePath | $DSH_HOME/plugins/dsh-autofill-model-metadata/models.dev.json | catalog cache |
cacheTTLHours | 24 | cache lifetime; 0 refetches every time |
applyOnStart | true | apply once when the plugin mounts |
dryRun | false | plan and log, write nothing |
requireCompleteMapping | true | undeclared models on a mapped route are an error |
builtinSettingsForm | "auto" | auto / always / never — see Settings |
Manual run, from any local browser session (<port> is the DSH web server):
BASE=http://127.0.0.1:<port>/api/dsh-autofill-model-metadata
curl -X POST $BASE/apply -H 'content-type: application/json' -d '{}' # {"route":"..."} to narrow
curl $BASE/status # mapping state, no catalog fetch
curl $BASE/inspect # same, resolved against the catalog
curl "$BASE/catalog?q=gpt-5.4&limit=10" # candidate mapping targets
curl -X POST $BASE/mapping -H 'content-type: application/json' \
-d '{"mapping":{"my-route":{"fast-model":"openai/gpt-5.4"}},"apply":true}'
Every endpoint is loopback-only and answers 403 to anything else; /apply,
/mapping and /page-live are POST, the rest are GET.
/mapping answers 200 once the write lands, even when a requested fill is then
refused: the mapping really was saved, and a failed pass is reported separately in
applyError rather than disguised as a failed save. /status and /inspect
answer with the mapping read from the settings document, not from the volatile
copy the loader refreshes a moment after a write.
Settings
The plugin ships a graphical settings page, and it also honours the host's
autoGenerate flag for the schema above — so a host that renders generated
forms, now or in a later version, keeps showing one whenever this page is not
there.
builtinSettingsForm | behaviour |
|---|
"auto" (default) | the generated form stays available until the browser half loads and reports in (POST /page-live) |
"always" | the generated form is never suppressed |
"never" | the generated form is suppressed as soon as the plugin mounts |
true/false are accepted as aliases for always/never.
What "the original form" means in 0.2.0-rc.2. It is worth being exact here:
the shipped client contains no generic schema-driven form renderer at all. Every
official settings page (Models, Shell, Agent loop, …) is a hand-written
companion package, and autoGenerate is carried by the settings transport but
consumed by nothing yet. So on this version the surfaces for a plugin config are:
- this page, when its bundle loads;
- the profile's own configuration document, always — hand-editable, and the
thing the page writes back to;
- a generated form, on any host that starts rendering one.
The auto policy is what keeps (3) honest: it does not suppress the flag until
the page has actually reported in, so a page that never loads never costs you a
surface. The page's own failure banner says the same thing when its host
endpoints are unreachable.
The page appears as Settings → Model metadata. It lists only relevant
routes — a route the profile configures, or one that appears in the mapping — so
the dozens of providers the host advertises but nobody configured cannot bury
the ones that matter. A mapped route the host does not offer is still listed,
with its reason, so a typo cannot hide. Each card shows: every declared model, the fields it declares by hand (never overwritten),
what a pass would fill, the mapping source in an editable field with catalog
search, and the refusals that would apply. Save mapping writes the whole
table in one revision-fenced op; Save and fill does that and runs a pass.
Nothing is written until you save, unsaved edits survive a reload, and a dry run
or an already-complete route is reported as exactly that rather than as a fill.
Both the page and the config document end up in the same settings service. The
page reads the settings revision along with its state and sends it back when
saving, so an edit made in the config document while the page sat open makes the
save fail closed — the page is told the mapping moved and to reapply — rather
than replacing the whole table with what the page happened to be showing. A fill
pass re-reads and re-plans the route inside the same fence, so a model added by
hand between planning and writing is never dropped: the pass refuses instead.
Without a revision from the caller there is nothing to compare against, so the
freshest one is used and the write proceeds.
What gets filled
| local field | from models.dev | rule |
|---|
name | model.name | |
contextWindow | limit.context | written only when > 0 |
maxTokens | limit.output | written only when > 0 |
input | modalities.input | image present → ['text','image'] |
reasoningEfforts | reasoning_options (effort values) | see below |
reasoningEfforts follows the pi-ai schema, where a map containing only off
is invalid:
- effort values present → map them, including
none → off;
- only
off usable, or reasoning: false → false;
- catalog says nothing → field left absent, so the profile's own default stands.
A model is eligible only when tool_call === true and status !== 'deprecated'.
Eligibility is checked but never written — the plugin does not flip tool_call.
Refusals
Per route, all-or-nothing. A refused route writes nothing.
| kind | when |
|---|
malformed-source | the mapping value is not provider/model |
unknown-provider | that provider id is not in the catalog |
unknown-model | that model id is not under that provider |
ambiguous-source | several catalog models match case-insensitively |
not-agent-capable | matched model cannot call tools, or is deprecated |
missing-mapping | requireCompleteMapping and a route model is undeclared |
undeclared-mapping | a mapping points at a model the route does not configure |
undeclared-mapping fires even with requireCompleteMapping: false, so a typo
in a local id is never silently ignored.
Design notes
- One atomic write per route: a single
set op at
['providers', <route>, 'models'], fenced by the revision re-read immediately
before mutate. Routes are independent — one refusal does not block another.
- Inert when unmapped: with
mapping: {} there is no catalog fetch, no
cache write, no settings read.
- Offline tolerant: a failed fetch falls back to the cached catalog and logs;
a stale cache is used rather than refusing.
- User layer wins: the merge base is the settings user layer when present.
Development
pnpm install
pnpm run verify # tsc --noEmit && tsdown && vitest
verify runs the whole suite: unit tests per module, a composition test driving
apply() against a stub host, a contract test validating planned output against
the real @deepseek-ai/dsh-llm-pi-ai@0.2.0-rc.2 schema (it skips itself if that
package is absent), render tests for the settings page, and a client-bundle test
that loads the built lib/client.js through a stub window.__ModuleLoader__
with the same seeded modules the shell provides.
Two halves
| half | entry | output | loaded by |
|---|
| host | src/index.ts | lib/index.mjs (ESM) | the DSH Loader, via the cordis.patch.yml row |
| browser | src/client/index.tsx | lib/client.js (CJS + window.__ModuleLoader__ banner) | the Web shell, via dsh.client in package.json |
react, react/jsx-runtime and @deepseek-ai/dsh-client-ui-primitives are
external and resolved by the shell's module seed — bundling a second React would
break hooks. The bundle id must stay the package name: it is the cache key the
loader serves at /plugins/<package>/client.js.
Client-only changes need the shell to re-read the bundle: a rebuild is enough
while the client-plugin HMR receiver is active, otherwise restart DSH Desktop.
macOS: vitest cannot start under the DSH runtime node
The node shipped with DSH Desktop is signed with the hardened runtime, while
rollup's native binary is ad-hoc signed. macOS refuses the load
(ERR_DLOPEN_FAILED, "mapping process and mapped file (non-platform) have
different Team IDs"), so vitest dies before collecting. Re-sign a copy of node
ad-hoc and run the suite with it:
mkdir -p .devnode && cp "$(command -v node)" .devnode/node
codesign --force --sign - .devnode/node
PATH="$PWD/.devnode:$PATH" pnpm run verify
.devnode/ is git-ignored. This affects any project in this environment that
runs vitest, including dsh-deep-sleep.
Limitations
- Reads models.dev only; no other metadata source.
- Never invents a mapping: unmapped models stay untouched.
- Does not touch
tool_call, status, cost, or compat.
- Applies on mount and on config edit; there is no scheduled refresh — use the
apply endpoint or restart to force one.