dsh-plugin-model-proxy
Community DSH plugin: route specific provider/model pairs through specific proxies (http://, https://, socks5://, socks5h://) — with a Settings UI.
Why
Some model endpoints are only reachable through a proxy while their siblings
connect fine — for instance opencode/muse-spark-1.2-contributor may answer
403 RegionError to direct connections that other models from the same
provider never see. The coarse fixes don't help: process-wide proxy
environment variables drag every request through the tunnel, and swapping a
whole provider's baseURL hides the real upstream. This plugin routes
specific provider/model pairs at the transport layer instead — everything
else stays direct, and the configured endpoint is never rewritten.
Features
- Per-rule routing:
{provider, model, proxyUrl, enabled}— specificity: exact model > prefixmuse-*>*. - Purpose filter: optional per-rule
purpose(e.g.compaction) so chat goes through the proxy while background calls stay direct. - credentialRef: keep proxy passwords in the DSH credentials service instead of settings.yaml; rules reference them by name (
user:passwordentries). Soft dependency — installs without it keep working. - Auto probe: newly configured proxies are connectivity-tested once (CONNECT/socks handshake, no model quota); results go to the host log.
- Protocols:
http://,https://(CONNECT viaundici.ProxyAgent),socks5:///socks5h://(via optionalsocks, tunnelled by undiciAgent+ custom connect). - Zero baseURL mutation — keep the real upstream.
- Live: change rules → next
llm/streamuses them; in-flight streams unaffected. - UI:
Settings → Plugins → Model Proxycard (also works via~/.dsh/settings.yaml). - Provider picker: dropdown groups user-configured providers first (derived from
llm.providers× settings mirror, same semantics as the built-in Models page); bare directory routes follow, and "Custom…" accepts anything — hand-written yaml rules, wildcards, gateways the catalog doesn't know. Provider and model fields are dropdowns fed by the live host catalog (llm.providers/llm.models, refreshed onllm/adapters-updated); a "Custom…" entry keeps free text for wildcards (muse-*,*) or not-yet-installed providers. - Batch & grouped management: adding rules checks multiple models for one provider at once — one rule each, sharing proxy/purpose/credential; the list groups cards by provider with group-level apply-proxy, enable/disable-all, and delete (cross-provider grouping never affects match order).
Install
Recommended: profile-managed (bundle)
The package declares a dsh.bundle layer, so the DSH CLI links the dependency
and appends the activation row in one step:
dsh plugin --profile <name> add dsh-plugin-model-proxy
# optional SOCKS support:
dsh plugin --profile <name> add socks
Verify the composed layer without booting, then run:
dsh --profile <name> --dump-config # shows a "# == dsh-plugin-model-proxy" layer
dsh --profile <name>
Uninstall removes both the dependency and the layer:
dsh plugin --profile <name> remove dsh-plugin-model-proxy
Installing from GitHub (dsh plugin --profile <name> add github:<user>/dsh-plugin-model-proxy#<sha>)
fetches sources; pnpm ≥10 asks you to allowlist the prepare build — copy the
package key it prints into the profile's pnpm-workspace.yaml under
allowBuilds:, then re-run the add. Pin a commit so later pushes cannot
change what runs on your machine.
Manual: hand-written patch
If you manage compositions by hand, install the dependency into the profile
directory first (dsh plugin --profile <name> add ./ from a checkout works;
module resolution anchors at the profile). Then insert exactly ONE loader row:
# ~/.dsh/cordis.patch.yml (or a --patch overlay)
- insert:
- id: model-proxy/host
name: dsh-plugin-model-proxy
config: {}
Patch-file rules that trip people up:
- The file must parse as a top-level YAML array of patch entries. Comments
are fine; a file of only comments (no
- …rows and no[]) fails to parse and breaks every boot/dump that reads it. - The loader-entry specifier field is
name:(the package name), notmodule:. - New rows must be nested under
insert:— a bare- id:row means "patch an existing entry". - Only the host half is a loader entry. The browser half is discovered automatically
from the package's
dsh.clientdeclaration and served at/plugins/dsh-plugin-model-proxy/client.js. Do NOT add amodel-proxy/clientrow — it would run browser code inside the Node process. - Never mix this path with the profile-managed install above. Once the
dependency declares
dsh.bundle.patch,dsh plugin addinjects the host row automatically; keeping a hand-written copy in~/.dsh/cordis.patch.ymlinserts the same loader id twice and the composition collides at boot.
Uninstall = remove the inserted rows + pnpm remove dsh-plugin-model-proxy
from the profile.
Configure
Via UI
Settings → Plugins → Model Proxy
Via file
~/.dsh/settings.yaml:
model-proxy:
enabled: true
debug: false
defaultProxy: "" # fallback when no rule matches
rules:
- provider: opencode
model: muse-spark-1.2-contributor
proxyUrl: socks5://127.0.0.1:1080
enabled: true
- provider: opencode
model: "*"
proxyUrl: "" # direct for the rest of this provider
proxyUrl: "" means direct (exempt). socks5h:// resolves DNS at the proxy.
How it works (non-invasive)
- Host registers
model-proxysettings namespace (installSettingsSection, live). - Wraps
globalThis.fetchreversibly. - Listens on
llm/streamwaterfall, resolvesproxyUrlfor(provider, model)viaAsyncLocalStorage, then injects adispatcherinto the adapter'sfetch— for proxied requests it routes throughundici.fetch(Node's native global fetch ignores custom dispatchers), withundici.ProxyAgentforhttp(s)and an undiciAgent+ socksconnect(with TLS) forsocks5/h. - Browser registers
settings.plugin.itemwith keymodel-proxy— automatically paired by the Plugins tab (served ∩ registered).
No Adapter fork, no baseURL rewrite. See DESIGN.md for full design and invasiveness analysis.
Invasiveness
| Check | Result |
|---|---|
Modifies packages/* |
No |
Requires fork of dsh-llm |
No |
| Global side effect reversible | Yes (ctx.effect dispose restores fetch) |
| Client bundle purity gate | Passes (only type-only slot import) |
Building
pnpm install
pnpm run build
Testing
Fully offline (local origin + CONNECT-capable toy proxy; no real network or proxy needed):
pnpm test # builds host + client, then runs node --test tests/
pnpm run typecheck
License
MIT
No comments yet. Be the first to write one.