# dsh-trustedrouter: plan A community [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh) plugin that lets users choose [TrustedRouter](https://trustedrouter.com/docs/provider-routing) privacy and routing options **per model** from the dsh web UI. Anyone can install it from GitHub. **Status (2026-10-07):** Phases 0 to 4 done: **v0.1.0 is released** and the repo is public ([release](https://github.com/AmmarByFar/dsh-trustedrouter/releases/tag/v0.1.0)). The host half (`index.js`) and the client half (`client.js`) are built. 89 unit tests pass (27 host, 62 client), the headless e2e suite passes, a clean install from GitHub works, and the UI was checked in a browser, all on dsh 0.2.0-rc.2 and 0.2.1-alpha.1. README, LICENSE, CHANGELOG and the CI workflow are written, and CI passes on GitHub (see [Phase 3 results](#phase-3-results-2026-10-07) and [Phase 4 results](#phase-4-results-2026-10-07)). **Next step is Phase 5 (migrate the maintainer's machine), which edits the live profile, so ask first.** All decisions are confirmed. ## Background: why a plugin - TrustedRouter takes routing and privacy policy only from the request body, as a `provider` object (e.g. `{"provider": {"min_privacy": "confidential"}}`). It accepts no header or API-key default for this. - dsh's pi-ai provider routes (`@deepseek-ai/dsh-llm-pi-ai`) accept extra `headers` but have **no body-field setting**: - pi-ai 0.87.1 has `onPayload`, `fetch` and `samplingParams`, but the dsh adapter (`packages/llm/llm-pi-ai/src/adapter.ts`, the `streamSimple(...)` call) passes none of them. - pi-ai's `compat.openRouterRouting` would send `provider`, but dsh marks it `withhold` in `catalog.ts`, so config refuses it. - The DeepSeek body-extension registry (`ctx.deepseekLlmApiExtensions`) only applies to the official DeepSeek adapter. - **Therefore the host half wraps `globalThis.fetch`.** This works because pi-ai builds a new OpenAI or Anthropic SDK client per request, and the SDK resolves the global `fetch` at construction. - Proven on dsh 0.2.0-rc.2: unit tests plus a headless end-to-end run against a mock. - In the maintainer's own use, the logs showed `provider` added to real chats. - The working prototype lives outside this repo (see `CLAUDE.local.md`). Phase 1 grows it into this repo. ## Decisions | # | Decision | Status | |---|---|---| | 1 | Repo `AmmarByFar/dsh-trustedrouter`, MIT licence, `dsh-plugin` GitHub topic | **Confirmed** (repo name and owner) | | 2 | Models page offers: privacy floor, provider allow/block (`only`/`ignore`), `sort`, US-only (`jurisdiction`), billing (`usage`). Advanced fields (`max_price`, `order`, `allow_fallbacks`, raw JSON) go on the plugin's own Plugins page | **Confirmed** 2026-10-07 | | 3 | GitHub first; npm publish later | **Confirmed** 2026-10-07 | | 4 | Per-chat (composer) privacy override is **not** in v1 | **Confirmed** 2026-10-07. Wanted for v2 or later; see [Later (v2+)](#later-v2) | | 5 | Public vs private repo during development | **Done:** private during development, **public since 2026-10-07** (Phase 4) | "Official" is not achievable. dsh's CONTRIBUTING.md says the team is not accepting outside PRs, and the Plugins page's "Official" group is a hard-coded list of plugins shipped with dsh. The sanctioned route is a public repo with the `dsh-plugin` topic, which community registries pick up automatically. ## Design ### Package layout: plain JS, no build step ``` package.json exports {".": "./index.js", "./client": "./client.js", "./locale/*.json", "./icon", "./package.json"}, top-level "icon": "./icon.svg", dsh.bundle.patch, dsh.client {platform: "web", inject: [...]}, peerDependencies + devDependencies {"@deepseek-ai/schemastery"} index.js host half: volatile Config schema plus the fetch wrapper with per-model merge client.js browser half: window.__ModuleLoader__.load({ id, factory(require) {...} }), React via require('react') cordis.patch.yml bundle layer: inserts row `id: trustedrouter`, `name: dsh-trustedrouter` (the bare package name, needed for the client half) locale/en.json { "meta": { "title", "description" } } (zh.json optional) icon.svg test/ node:test unit tests (host and client), test/e2e (headless dsh against mock-openai.mjs), test/ui/scratch-web.sh (a scratch `dsh web` with this repo installed by `link:`) ``` - **No build step.** A git install then needs no `prepare` script, so pnpm doesn't ask for `allowBuilds`. Verified in Phase 0. - **Template to copy.** dsh ships a plain-JS starter: `packages/preset/agent-preset/skills/cordis-plugin-development/templates/decoration/` (`package.json`, `cordis.patch.yml`, `index.js`, `client.js`, `locale/en.json`, `icon.svg`). - The same skill folder has `references/ui-plugin.md` and `references/practices.md`. - It is also present in the installed rc.2 copy at `node_modules/@deepseek-ai/dsh-agent-preset/skills/...`. - **Dependencies.** Do not declare `@deepseek-ai/dsh*` peers. The install-time compatibility gate requires every `dsh*` peer range to satisfy the running version, and `^0.2.0` does not satisfy `0.2.0-rc.2`. `@deepseek-ai/schemastery` and `@deepseek-ai/cordis` are not gated. - **How the schemastery peer resolves (verified in Phase 0).** Profiles use `nodeLinker: hoisted` and `autoInstallPeers: false`, so pnpm installs no copy and prints only a peer warning. dsh's "runtime resolution" then answers the import from its own install. - **Path-mounted rows don't get that resolution.** A row like `name: /abs/path/index.js` fails with "failed to import". Local dev therefore needs schemastery in the repo's `node_modules`, for both `node --test` and path-mounted headless e2e. Make it a devDependency in Phase 1; Phase 0 used a symlink to rc.2's copy. - **Client imports.** The client may `require` only the shell's shared modules: - `react`, `react/jsx-runtime`, `react-dom`, `react-dom/client` - `@deepseek-ai/cordis` - `@deepseek-ai/dsh-client-store`, `dsh-client-ui-slots`, `dsh-client-ui-primitives`, `dsh-client-ui-dockkit` The template's practices doc advises plain-JS plugins not to require `ui-primitives`; copy the markup and use `--dsw-alias-*` CSS tokens instead. - **Boot risk.** A malformed `dsh.client` declaration or a missing `client.js` fails the client-modules fiber, which can break web boot. Test installs carefully. - **`immediately`.** The template sets `dsh.client.immediately: true`, but dsh's AGENTS.md says it's for infrastructure rows only. Phase 0 works without it; leave it out. - **`dsh.client.inject`.** `["@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-ui-plugin-manager", "@deepseek-ai/dsh-client-ui-settings-models"]` works on rc.2 and alpha. The list follows the agent-loop example, plus the Models page; the entries only order activation. - **Icon.** rc.2 reads a plugin icon only from the exported `./package.json`'s top-level `"icon"` field (`readPluginMeta` in `dsh-app-boot`). The `./icon` export fallback exists only in the 0.2.1-alpha source. The spike exported `./icon` alone and got the default artwork. Do both. ### Host half (`index.js`): built in Phase 1 Config is a `@deepseek-ai/schemastery` schema. Every field is `.volatile()`, so UI saves commit in place without a remount. Each request reads all five values once. Pattern: llm-pi-ai's `providers: z.dict(profile).default({}).volatile()`, and `docs/cookbook/adding-a-settings-card.md` §1 and §3. ```yaml - id: trustedrouter name: dsh-trustedrouter config: gateways: [https://api.trustedrouter.com/v1] # the default; EU gateway: https://api-europe-west4.quillrouter.com/v1 defaults: { min_privacy: zdr } # `provider` fields for every model on those gateways; default {} models: # per-model overrides, keyed by the request body's `model`; default {} z-ai/glm-5.3: { min_privacy: confidential, only: [tinfoil, privatemode] } rules: [] # escape hatch from the prototype: [{ baseURL, body }] deep-merged into any POST body log: false # true prints "[trustedrouter] ..." lines to stderr (the dsh web terminal) ``` **Out of the box nothing changes.** The bundle row has no config, so the schema defaults apply: the TrustedRouter gateway, but no `defaults` and no `models`. Installing the plugin never starts producing 400s by itself; the user picks the policy. **Schema (`Provider`).** - The documented `provider` fields are type-checked, so a bad value fails at save time. Strict enums: `min_privacy` (with aliases `e2e` and `e2ee`), `sort`, `data_collection` and `usage` (with its aliases). `jurisdiction` is a free string so new jurisdictions need no update. - **Unknown keys pass through.** schemastery's `object` keeps unknown keys and can't declaratively reject them. That's safe because TrustedRouter's docs say unknown options return 400: a misspelled key fails loudly at TrustedRouter instead of vanishing. - **Gotcha:** schemastery fills a missing nested `object` field with `{}`. `max_price` therefore uses `.default(undefined)`; without it every request carried `max_price: {}`. Fields declared as unions (`sort`, the percentile fields, the lists) aren't filled in. - URLs (`gateways`, `rules[].baseURL`) must match `^https?://host[/path]`, with no query or fragment. A URL that passes but that `new URL` still rejects matches nothing; it never refuses other traffic. - **The schema survives the browser's JSON round trip.** The browser rebuilds it with `new Schema(json)` and validates values (checked in Node in Phase 1). Volatile `.get()` values are frozen and schemastery's dict resolver writes to its input, so never feed snapshots back through the schema. **Request handling:** - Only POSTs to a URL under a gateway (path-segment boundary) or under a rule's `baseURL` are considered. GETs (model discovery) and other URLs pass through untouched. - **The provider policy applies only when there is one** (non-empty `defaults` or `models`). With none, gateway POSTs aren't even parsed. - `provider = merge(defaults, models[model])`, and that result is merged over the request's own `provider`, so **the policy wins conflicts**. A non-object request `provider` is replaced. Arrays replace rather than concatenate. - **Model lookup:** an exact key first. Then, for an id with a routing suffix (`z-ai/glm-5.3:nitro`), the base model's key, so a floor set for a model also holds for its variants. Lookups use `Object.hasOwn`, so ids like `constructor` don't hit prototype keys. - **Rules:** every matching rule is deep-merged in order, after the provider policy, so rule fields win. The prototype applied only the first match. - A body that ends up unchanged (e.g. its model has no policy) is forwarded with the original arguments, byte for byte. Otherwise the body is re-serialized and any stale `content-length` is removed. - **Fails closed:** a POST that needs fields but whose body isn't a JSON object (including when the model can't be read) is rejected with `trustedrouter: refusing POST : ... so provider cannot be added`. The refusal is always printed to stderr, even with `log: false`. - **Logging goes to `console.error`, not `ctx.logger`.** Shipped dsh profiles mount no Cordis log exporter, so `ctx.logger` output is dropped. With `log: true`: - on load and on every `loader/volatile-update`: `provider policy for POSTs under : defaults {...}, overrides for `, plus one `merging {...} into POST bodies under ` line per rule; - per request: `added provider to POST for : {...}`, or `no provider policy for ; POST sent unchanged`, and `added to POST (rule for )` per rule. Request bodies are never printed; the model id and the configured fields are. - **Unloading:** restore the previous `globalThis.fetch` if ours is still on top; otherwise become a pass-through. ### Client half (`client.js`): built in Phase 2 One `window.__ModuleLoader__.load({ id: 'dsh-trustedrouter', factory })` script. The factory requires only `react`, renders with `createElement`, and returns `{ inject: ['slots', 'locale', 'configForms'], apply }` plus its pure helpers, which `test/client.test.js` imports, as the host half exports its own. Styles are a `