dsh-openrouter-plus 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/LICENSE +21 -0
- package/README.md +420 -2
- package/client.js +3503 -0
- package/cordis.patch.yml +84 -0
- package/docs/ARCHITECTURE.md +385 -0
- package/docs/DESIGN.md +104 -0
- package/docs/images/composer-chip.jpg +0 -0
- package/docs/images/models-route.jpg +0 -0
- package/docs/images/settings-overview.jpg +0 -0
- package/docs/images/settings-presets.jpg +0 -0
- package/icon.svg +28 -0
- package/index.js +1299 -0
- package/lib/auto-repair.js +110 -0
- package/lib/config-bridge.js +838 -0
- package/lib/metrics.js +149 -0
- package/lib/proxy.js +1005 -0
- package/lib/reasoning.js +203 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +68 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Notable changes to `dsh-openrouter-plus`. Format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
|
|
5
|
+
[SemVer](https://semver.org/), and the project stays below 1.0 while the routing presets and
|
|
6
|
+
the config keys can still change.
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
Nothing yet.
|
|
11
|
+
|
|
12
|
+
## [0.1.1] — 2026-10-27
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- The UI says **preset** everywhere. The recent-requests column header read `Tier`, the provider
|
|
17
|
+
row hint and the empty state said "service tier", and the Chinese strings said 服务等级; the
|
|
18
|
+
rename to presets had missed all five.
|
|
19
|
+
- README gains screenshots of the provider switch, both halves of the settings page, and the
|
|
20
|
+
composer chip — the four states worth seeing before installing.
|
|
21
|
+
|
|
22
|
+
## [0.1.0] — 2026-10-26
|
|
23
|
+
|
|
24
|
+
First release: a loopback relay that applies an OpenRouter **routing preset** to a request at
|
|
25
|
+
send time, chosen per Session from the composer.
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- **Nine routing presets**, each a named wire effect rather than a price promise: *No pin*,
|
|
30
|
+
Standard (`service_tier: "default"`), Fastest · any price (`provider.sort: "throughput"`),
|
|
31
|
+
Fastest · standard price, Cheapest (`provider.sort: "price"`), Flex
|
|
32
|
+
(`service_tier: "flex"`), Priority (`service_tier: "priority"`), Ultra
|
|
33
|
+
(`service_tier: "ultrafast"`), Floor (the `:floor` model variant).
|
|
34
|
+
- **A preset chip in the composer**, next to the model selector: the choice applies to one
|
|
35
|
+
Session, is never written to a provider row, and the first entry returns the Session to the
|
|
36
|
+
profile default.
|
|
37
|
+
- **OpenRouter Plus settings page**: relay state and origin, one pinned base URL per preset with
|
|
38
|
+
a copy button, live request/token counters per tier, the measured effective multiplier, recent
|
|
39
|
+
requests, and a provider-route switch that repoints one existing row at the relay.
|
|
40
|
+
- **Pinned base URLs** (`/v1`, `/default/v1`, `/flex/v1`, `/priority/v1`, `/ultrafast/v1`) for the
|
|
41
|
+
case a per-Session choice cannot cover: a route pinned this way keeps its preset for every
|
|
42
|
+
Session on it and across restarts.
|
|
43
|
+
- **`defaultPreset`** — the preset an unchosen Session inherits, edited on the settings page.
|
|
44
|
+
- **A relay control API** on loopback only: `status`, `targets`, `tier`, `traffic`, plus the
|
|
45
|
+
route attach/detach/repair endpoints the settings page drives.
|
|
46
|
+
- **Reasoning-effort fill** for relay-routed models, so a route that points at the relay keeps the
|
|
47
|
+
thinking-level selector.
|
|
48
|
+
|
|
49
|
+
### Notes on the wire
|
|
50
|
+
|
|
51
|
+
- No preset is ever sent as `provider.sort` together with `service_tier: "flex"`, and `default`
|
|
52
|
+
is only written when the preset asks for it, never as a default for an unset choice.
|
|
53
|
+
- `ultrafast` is OpenAI-only and needs `max_tokens`; the relay warns when a Session sets it
|
|
54
|
+
without one and never injects the field.
|
|
55
|
+
- A `provider` block on the request is merged, not replaced, so `provider.only`, `provider.order`
|
|
56
|
+
and `provider.ignore` survive every preset.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-openrouter-plus contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,421 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-openrouter-plus
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-openrouter-plus)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
**Service-tier routing for OpenRouter — as a DeepSeek Harness plugin, with no patched packages.**
|
|
7
|
+
|
|
8
|
+
OpenRouter lets you steer where a request is served from: `flex` endpoints bill half price on a
|
|
9
|
+
slower pool, `priority` endpoints bill a multiplier for lower latency, and the throughput or price
|
|
10
|
+
comparators reorder the pool in between. In `@deepseek-ai/dsh-llm-pi-ai` none of that is reachable —
|
|
11
|
+
provider profiles expose `baseURL` and `headers`, but no request-body fields
|
|
12
|
+
([discussion #9787](https://github.com/deepseek-ai/deepseek-harness/discussions/9787)).
|
|
13
|
+
|
|
14
|
+
This plugin reaches the same wire result from the outside. It runs a **loopback relay**; a provider
|
|
15
|
+
route points at it, and the relay writes the routing fields into the request body on the way
|
|
16
|
+
upstream — `service_tier`, `provider.sort`, or the `:floor` model variant, depending on the
|
|
17
|
+
**preset** you picked. Because it only uses schema fields the profile already has, it survives
|
|
18
|
+
`bun install -g` upgrades and never touches `~/.bun/install/global`.
|
|
19
|
+
|
|
20
|
+
The preset is chosen **per session, at request time**. One provider row keeps serving every preset, so
|
|
21
|
+
switching never mints a provider, a route or a model list.
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
composer chip: this session runs on «priority»
|
|
25
|
+
│
|
|
26
|
+
▼
|
|
27
|
+
dsh-llm-pi-ai ──► dsh-openrouter-plus relay ──► https://openrouter.ai/api/v1
|
|
28
|
+
baseURL = │ reads the session from x-session-id / prompt_cache_key,
|
|
29
|
+
127.0.0.1:8791/t/<id>/v1 │ applies that session's preset to the body
|
|
30
|
+
└── /__openrouter-plus/* ──► OpenRouter Plus settings page
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## What you get
|
|
34
|
+
|
|
35
|
+
- **One provider row, any preset.** A routed provider keeps its key, protocol and models and gains
|
|
36
|
+
one thing: a composable **preset** that the relay applies to each request. No per-preset provider
|
|
37
|
+
copies, no sibling route per preset, no model list duplicated nine times.
|
|
38
|
+
- **Pinned base URLs** for what a chip cannot cover — `/v1` (no pin), `/default/v1` (Standard),
|
|
39
|
+
`/flex/v1` (Flex), `/priority/v1` (Priority), `/ultrafast/v1` (Ultra). Paste one into an
|
|
40
|
+
OpenAI-compatible provider route and that preset applies to *every* Session on the route,
|
|
41
|
+
subagents included, and survives a restart. The relay still serves the older names
|
|
42
|
+
(`/auto/v1`, `/fast/v1`, `/scale/v1`); the settings page lists one mount per preset so it never
|
|
43
|
+
offers the same choice twice.
|
|
44
|
+
- **Its own settings page** (Settings → OpenRouter Plus) showing relay state, one pinned base URL
|
|
45
|
+
per preset with a copy button, live request/token counters per preset, the measured effective
|
|
46
|
+
multiplier, and recent requests.
|
|
47
|
+
- **A preset chip in the composer** — next to the model selector, a compact chip shows the preset
|
|
48
|
+
this Session will actually apply and switches it in one click. The switch is **per session**: it
|
|
49
|
+
changes what the next request from *this* conversation carries, so two conversations can sit on
|
|
50
|
+
`Flex` and `Priority` at the same time. It shows up on a brand-new Session too — before the first
|
|
51
|
+
message the chip reads the profile default, so a preset can be picked *before* you start typing.
|
|
52
|
+
The reasoning effort you chose stays put: switching a preset touches no provider row and no model
|
|
53
|
+
list.
|
|
54
|
+
- **A switch on every provider row** — in Settings → Models, each `llm-pi-ai` provider gets a
|
|
55
|
+
**Route through OpenRouter Plus** checkbox. Tick it and that provider's traffic flows through the
|
|
56
|
+
relay to the endpoint you already gave it; untick it and the endpoint comes back. Your provider
|
|
57
|
+
entry, protocol, key and models are left exactly as they were.
|
|
58
|
+
- **A default preset for the profile** — the settings page sets what an unchosen Session inherits
|
|
59
|
+
(and explains what every preset sends), and the composer chip's `Profile default (…)` entry hands
|
|
60
|
+
a Session back to it.
|
|
61
|
+
- **Zero risk to your existing setup** — the relay holds no API key, forwards your `Authorization`
|
|
62
|
+
header verbatim, binds only to loopback, and the only profile changes it makes are reversible by
|
|
63
|
+
construction.
|
|
64
|
+
|
|
65
|
+
## Screenshots
|
|
66
|
+
|
|
67
|
+
The switch lives on the provider you already use, in Settings → Models. Ticking it moves that
|
|
68
|
+
row's traffic onto the relay and changes nothing else:
|
|
69
|
+
|
|
70
|
+

|
|
71
|
+
|
|
72
|
+
The settings page — relay state, one pinned base URL per preset, the counters, and what each
|
|
73
|
+
preset actually sends:
|
|
74
|
+
|
|
75
|
+

|
|
76
|
+
|
|
77
|
+

|
|
78
|
+
|
|
79
|
+
The composer chip, which switches the preset for one Session without touching any provider row:
|
|
80
|
+
|
|
81
|
+

|
|
82
|
+
|
|
83
|
+
## Install
|
|
84
|
+
|
|
85
|
+
`--profile` is not optional — `dsh plugin` manages the plugins of one named profile, so pass the
|
|
86
|
+
one you actually boot (`web` for `dsh web`, `default` for a bare `dsh`). Without it the CLI stops
|
|
87
|
+
with `required option '--profile <name>' not specified`.
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
dsh plugin --profile web add dsh-openrouter-plus # from npm
|
|
91
|
+
dsh plugin --profile web add github:GnaneshKunal/dsh-openrouter-plus#v0.1.0 # or a pinned tag
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
From a clone, which is what development uses:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
git clone https://github.com/GnaneshKunal/dsh-openrouter-plus
|
|
98
|
+
dsh plugin --profile web add ./dsh-openrouter-plus # or, from a session:
|
|
99
|
+
# plugin_manager { action: install_bundle, target: "./dsh-openrouter-plus" }
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The manifest declares `engines.dsh`, so the plugin manager refuses a harness older than the build
|
|
103
|
+
it was tested against instead of mounting something that cannot work.
|
|
104
|
+
|
|
105
|
+
The plugin adds one entry to Settings navigation: **OpenRouter Plus**, directly after *Models*. If
|
|
106
|
+
the relay is up, the page shows the origin (`http://127.0.0.1:8791`) and its mounts.
|
|
107
|
+
|
|
108
|
+
## Releasing
|
|
109
|
+
|
|
110
|
+
`.github/workflows/ci.yml` runs `npm run check`, `npm test` and `npm pack --dry-run` on every push
|
|
111
|
+
and pull request. `.github/workflows/release.yml` turns a tag into a release:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
$EDITOR package.json CHANGELOG.md # version 0.2.0, and a "## [0.2.0] — 2026-11-02" section
|
|
115
|
+
git commit -am "chore: 0.2.0" && git push
|
|
116
|
+
git tag v0.2.0 && git push origin v0.2.0
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The job refuses a tag that disagrees with `package.json`, runs the suite, publishes to npm, and
|
|
120
|
+
opens the GitHub Release with the matching `CHANGELOG.md` section as its notes. It fails rather
|
|
121
|
+
than skipping when npm already has that version: a release is a new version, never a re-upload.
|
|
122
|
+
|
|
123
|
+
npm needs the package to exist before it can trust a publisher, so `0.1.0` went up by hand, once,
|
|
124
|
+
from a logged-in machine:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
npm login
|
|
128
|
+
npm publish --access public
|
|
129
|
+
npm trust github dsh-openrouter-plus --file release.yml \
|
|
130
|
+
--repo GnaneshKunal/dsh-openrouter-plus --env release --allow-publish
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Every version after it is a tag, and nothing else. The tag does everything: the workflow run's OIDC identity is accepted, npm attaches a
|
|
134
|
+
provenance attestation linking the tarball to the commit and run that built it, and no
|
|
135
|
+
`NPM_TOKEN` exists in the repository. `npm trust list dsh-openrouter-plus` shows the grant.
|
|
136
|
+
|
|
137
|
+
Publishing to npm is optional for users: `dsh plugin --profile web add github:…` installs the same code from a
|
|
138
|
+
tag, which is how most of the community ships.
|
|
139
|
+
|
|
140
|
+
## Use it
|
|
141
|
+
|
|
142
|
+
### Two clicks: tick the provider, pick the preset
|
|
143
|
+
|
|
144
|
+
1. **Settings → Models** → tick **Route through OpenRouter Plus** on the provider you already use
|
|
145
|
+
(for example `openrouter-custom`). That provider's traffic now flows through the relay; its
|
|
146
|
+
`api`, `apiKeyEnv`, headers and models are untouched.
|
|
147
|
+
2. Open the composer of any Session and pick a preset in the chip next to the model selector. Every
|
|
148
|
+
request that Session sends now applies that preset.
|
|
149
|
+
|
|
150
|
+
Nothing else to create, and nothing to clean up: the provider row is the only row that ever existed,
|
|
151
|
+
and the preset is a per-Session choice rather than a provider.
|
|
152
|
+
|
|
153
|
+
The settings page manages the profile-level **Service tier for new sessions** — with a short table
|
|
154
|
+
of what each preset sends — and lists the routes that currently point at this relay (with **Remove**
|
|
155
|
+
for the ones you no longer want).
|
|
156
|
+
|
|
157
|
+
### From a provider row (the switch)
|
|
158
|
+
|
|
159
|
+
In **Settings → Models**, every `llm-pi-ai` provider row carries a compact strip added by this
|
|
160
|
+
plugin: a **Route through OpenRouter Plus** checkbox.
|
|
161
|
+
|
|
162
|
+
Ticking it does three things, all through the control API:
|
|
163
|
+
|
|
164
|
+
1. records the endpoint that row currently points at (that is your real base URL) as a *target* in
|
|
165
|
+
this plugin's own config;
|
|
166
|
+
2. gives that target a mount — `http://127.0.0.1:8791/t/<targetId>/v1` — which *mounts* the
|
|
167
|
+
endpoint without pinning a service tier: the presets come from the Sessions that call it;
|
|
168
|
+
3. repoints **only** that provider row's `baseURL` at the mount. `api`, `apiKeyEnv`, headers and
|
|
169
|
+
models are not touched, and no plugin bookkeeping is written into the provider entry (it is
|
|
170
|
+
schema-validated, so it stays clean).
|
|
171
|
+
|
|
172
|
+
**The first attach reloads the plugin.** Recording a new target is a write to this plugin's own
|
|
173
|
+
config, so the Loader re-applies the Host row and the listener rebinds — which can cut the HTTP
|
|
174
|
+
response to that very request. It is not a failure: the strip re-probes `/health`, re-reads
|
|
175
|
+
`/targets` and shows the truth, and every write here is idempotent, so a retry lands cleanly. Later
|
|
176
|
+
attaches (a detach, a re-attach of a known endpoint) reuse the target row and do not reload anything.
|
|
177
|
+
**A preset switch never writes anything**, so it never reloads anything either.
|
|
178
|
+
|
|
179
|
+
What happens to a request: the relay strips the mount prefix, appends the rest of the path to your
|
|
180
|
+
recorded endpoint, resolves the preset the Session carrying that request chose, and applies it to the
|
|
181
|
+
JSON body. `No pin` sends neither a tier nor a comparator — and a `service_tier` the client put there
|
|
182
|
+
itself is removed, so a Session that pinned nothing stays unpinned rather than inheriting whatever a
|
|
183
|
+
UI happened to send.
|
|
184
|
+
|
|
185
|
+
Untick it (**Detach**) and the row's `baseURL` goes back to the endpoint recorded for that target;
|
|
186
|
+
the mount stays until you remove the target. Everything is idempotent: the same endpoint always maps
|
|
187
|
+
to the same target id, so ticking the box twice cannot fork the table or repoint anything twice.
|
|
188
|
+
|
|
189
|
+
> **Why the switch is on the row and not inside the add-provider form.** The obvious place for this
|
|
190
|
+
> checkbox is the "Custom model API" form itself. A plugin cannot put it there: the
|
|
191
|
+
> `settings.models.provider-card` extension slot hands an extension `{provider, configured,
|
|
192
|
+
> keyConfigured}` and no setter, the draft Base URL lives in a private `useState` inside the editor,
|
|
193
|
+
> and that card dispatches no extension at all before the provider is saved. The saved row is the
|
|
194
|
+
> earliest surface a plugin can reach — same page, one save later. The same class of limitation is
|
|
195
|
+
> why a re-picked model loses hand-declared fields (tracked upstream as
|
|
196
|
+
> [discussion #11097](https://github.com/deepseek-ai/deepseek-harness/discussions/11097)).
|
|
197
|
+
|
|
198
|
+
### By hand
|
|
199
|
+
|
|
200
|
+
Point any OpenAI-compatible provider route's `baseURL` at a mount:
|
|
201
|
+
|
|
202
|
+
```yaml
|
|
203
|
+
- id: llm-pi-ai
|
|
204
|
+
config:
|
|
205
|
+
providers:
|
|
206
|
+
my-flex-route:
|
|
207
|
+
api: openai-responses
|
|
208
|
+
apiKeyEnv: OPENROUTER_API_KEY
|
|
209
|
+
baseURL: http://127.0.0.1:8791/flex/v1
|
|
210
|
+
models:
|
|
211
|
+
- id: deepseek/deepseek-v4.1-flash
|
|
212
|
+
# Optional but recommended — see "Reasoning effort" below.
|
|
213
|
+
reasoningEfforts:
|
|
214
|
+
off: null
|
|
215
|
+
low: low
|
|
216
|
+
medium: medium
|
|
217
|
+
high: high
|
|
218
|
+
max: max
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Or route a provider to its own endpoint through a target mount — no provider config needed beyond
|
|
222
|
+
the base URL:
|
|
223
|
+
|
|
224
|
+
```yaml
|
|
225
|
+
- id: llm-pi-ai
|
|
226
|
+
config:
|
|
227
|
+
providers:
|
|
228
|
+
acme-gateway:
|
|
229
|
+
api: openai-completions
|
|
230
|
+
apiKeyEnv: ACME_API_KEY
|
|
231
|
+
baseURL: http://127.0.0.1:8791/t/acme-gw-9f3a/flex/v1
|
|
232
|
+
models:
|
|
233
|
+
- id: acme-large
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
`/t/<targetId>/v1` is the shape the switch writes: it mounts the endpoint and pins nothing, so each
|
|
237
|
+
Session's chip decides. `/t/<targetId>/<tier>/v1` is the older pinned shape and still works — useful
|
|
238
|
+
when a row must sit on one tier regardless of who calls it. The target's endpoint is declared once,
|
|
239
|
+
in the plugin's config (see `targets` below), so one listener can front any number of OpenAI- or
|
|
240
|
+
Anthropic-style endpoints.
|
|
241
|
+
|
|
242
|
+
### Reasoning effort on a relay route
|
|
243
|
+
|
|
244
|
+
A relay route is a provider key the installed catalog has never seen, so
|
|
245
|
+
`@deepseek-ai/dsh-llm-pi-ai` cannot inherit a model's reasoning capability for it. Without an
|
|
246
|
+
explicit `reasoningEfforts` the model materializes as `reasoning: false` and the composer shows
|
|
247
|
+
**"This model provides no reasoning effort levels."** — no effort selector, no error, nothing in the
|
|
248
|
+
log. The relay itself is not involved; it never touches reasoning fields.
|
|
249
|
+
|
|
250
|
+
The plugin fills that gap when it creates a route, from the best evidence available:
|
|
251
|
+
|
|
252
|
+
1. a capability the model already declares (in the source route), kept byte-identical;
|
|
253
|
+
2. a declaration for another variant of the same id — `google/gemini-4-argon:flex` teaches the
|
|
254
|
+
plugin about `google/gemini-4-argon`;
|
|
255
|
+
3. the configured `reasoningEfforts` map, as a fallback for everything else.
|
|
256
|
+
|
|
257
|
+
Set `reasoningEfforts: false` to switch filling off, or omit it to fill only from evidence 1–2.
|
|
258
|
+
A model that declares `reasoningEfforts: false` is itself opted out: the fill leaves it alone.
|
|
259
|
+
|
|
260
|
+
**The fill also re-applies itself.** Re-picking a model in Settings → Models rebuilds that row from
|
|
261
|
+
the catalog — `id`, `name`, context window, max tokens — and drops `reasoningEfforts` along with
|
|
262
|
+
every other hand-declared field. That is the shape of this bug: the effort selector works, you touch
|
|
263
|
+
the model list afterwards, and it is gone. The cause is upstream rather than here (`adopt()` in
|
|
264
|
+
`@deepseek-ai/dsh-client-ui-settings-models` copies a fixed field list), so the plugin watches the
|
|
265
|
+
profile instead: a debounced pass runs on every config settle and restores the capability a moment
|
|
266
|
+
after the edit lands. Set `autoRepairReasoning: false` to keep the plugin out of your config
|
|
267
|
+
entirely, and use **Add reasoning to N models** in the settings page (or
|
|
268
|
+
`POST /__openrouter-plus/routes/repair`) when you want the fill on demand.
|
|
269
|
+
|
|
270
|
+
Either way it only ever touches routes whose `baseURL` points at the relay, never overwrites a
|
|
271
|
+
declared map, never resurrects an explicit `false`, and writes nothing when there is nothing to do.
|
|
272
|
+
|
|
273
|
+
## Presets
|
|
274
|
+
|
|
275
|
+
A service tier is not one field. OpenRouter exposes three levers — `service_tier` (which pools may
|
|
276
|
+
serve, and in what order), `provider.sort` (the comparator over that pool) and two slug suffixes
|
|
277
|
+
(`:nitro`, `:floor`) that mix the two — and each preset is a combination the API actually defines:
|
|
278
|
+
|
|
279
|
+
| Preset | Sends | What it does |
|
|
280
|
+
| --- | --- | --- |
|
|
281
|
+
| **No pin** | nothing | OpenRouter's own routing. |
|
|
282
|
+
| **Standard** | `service_tier: "default"` | The standard tier only; priority endpoints are excluded. |
|
|
283
|
+
| **Fastest · any price** | `provider.sort: "throughput"` | The whole eligible pool sorted by throughput. On an open-weight model this *is* nitro — its priority endpoints already compete in default routing. On a closed-weight one it stops short, because those endpoints are opt-in. |
|
|
284
|
+
| **Fastest · standard price** | `sort: "throughput"` + `service_tier: "default"` | The same race over the standard pool only, so the priority premium is unreachable. |
|
|
285
|
+
| **Cheapest** | `provider.sort: "price"` | Lowest price over the standard pool — usually a quantized version of the model. It never admits a flex endpoint, so it cannot reach a flex rate. |
|
|
286
|
+
| **Flex** | `service_tier: "flex"` | Flex endpoints only, at flex rates. Not necessarily the cheapest, and a flex capacity error surfaces instead of falling back to standard. |
|
|
287
|
+
| **Priority** | `service_tier: "priority"` | Priority endpoints serve first, then standard. OpenAI calls this Fast mode. The premium is charged only when a priority endpoint actually serves, and within each group endpoints sort by throughput unless you set a comparator. |
|
|
288
|
+
| **Ultra** | `service_tier: "ultrafast"` | ultrafast, then priority, then standard. OpenAI models only, and it needs `max_tokens` to hold — without one it silently degrades to priority. |
|
|
289
|
+
| **Floor** | `model: "…:floor"` | A blind price sort across the standard **and** flex pools. No parameter form exists for this, which is why it is a slug. `:nitro` is deliberately not offered separately: for the models where it changes the outcome, **Fastest · any price** is the same race. |
|
|
290
|
+
|
|
291
|
+
Two rows sit between "no tier" and "priority" for a reason: `provider.sort` and `service_tier`
|
|
292
|
+
decide different things. Sorting by throughput reorders whatever pool the tier admitted; pinning
|
|
293
|
+
`service_tier: "default"` is what keeps the premium out of it. Neither one alone can say
|
|
294
|
+
"fastest, and never at a premium".
|
|
295
|
+
|
|
296
|
+
### From the composer (the preset chip)
|
|
297
|
+
|
|
298
|
+
Once a provider row is routed, a compact chip appears in the composer next to the model selector,
|
|
299
|
+
showing the preset the next request will apply. Picking another one there does **not** rewrite any
|
|
300
|
+
config and does **not** mint a provider: the Host records the choice for this Session in memory, and
|
|
301
|
+
the relay applies it to the request body on the way out.
|
|
302
|
+
|
|
303
|
+
The menu carries labels only — `Fastest · any price`, `Cheapest`, `Flex`, `Priority`, … — and the
|
|
304
|
+
long explanation of what each one sends lives on the settings page, where it is read once. The first
|
|
305
|
+
entry, **Profile default (…)**, stops choosing for this Session and shows which preset it will
|
|
306
|
+
inherit.
|
|
307
|
+
|
|
308
|
+
That is why it is per session: two conversations can be on `Flex` and `Priority` at the same time,
|
|
309
|
+
neither of them rewrites the other's provider row, and the reasoning effort you selected survives
|
|
310
|
+
the switch because the selection itself never changes.
|
|
311
|
+
|
|
312
|
+
The choice lives in the Host's memory — it is runtime state, like the effort level — so it survives
|
|
313
|
+
the whole Session but not a Host restart (a default-preset write reloads the plugin, which drops
|
|
314
|
+
those choices too). Want a preset that outlives every restart? Either set the profile **Service tier
|
|
315
|
+
for new sessions** or point a provider at a pinned mount by hand (`/flex/v1`).
|
|
316
|
+
|
|
317
|
+
The chip only appears when the Session's current provider is routed through a mount
|
|
318
|
+
(`/t/<targetId>/…`). Elsewhere the Models-page switch is the control: it is the one that moves the
|
|
319
|
+
provider itself.
|
|
320
|
+
|
|
321
|
+
## Control API
|
|
322
|
+
|
|
323
|
+
Served on the same loopback listener, outside the proxy path space:
|
|
324
|
+
|
|
325
|
+
| Endpoint | Purpose |
|
|
326
|
+
| --- | --- |
|
|
327
|
+
| `GET /__openrouter-plus/health` | liveness |
|
|
328
|
+
| `GET /__openrouter-plus/status` | address, upstream, mounts, metrics |
|
|
329
|
+
| `GET /__openrouter-plus/targets` | source routes, routes on this relay, the `targets` table, and the preset table (`{ default, presets }`) |
|
|
330
|
+
| `GET /__openrouter-plus/tier?session=<id>` | what that Session would apply now: `preset`, `wire`, `chosen`, `default`, `presets` |
|
|
331
|
+
| `POST /__openrouter-plus/tier` | `{ session, preset }` — choose this Session's preset; `preset: null` hands it back to the profile default. **Memory only, never a config write** |
|
|
332
|
+
| `POST /__openrouter-plus/tier/default` | `{ preset }` — the preset an unchosen Session inherits; omit it (or send `none`) to pin nothing |
|
|
333
|
+
| `POST /__openrouter-plus/routes/remove` | `{ name }` |
|
|
334
|
+
| `POST /__openrouter-plus/routes/repair` | adds missing `reasoningEfforts` to the routes this plugin owns |
|
|
335
|
+
| `POST /__openrouter-plus/routes/attach` | `{ route, upstream }` — record the endpoint and point that provider at the relay |
|
|
336
|
+
| `POST /__openrouter-plus/routes/detach` | `{ route, id }` — put that provider back on its own endpoint |
|
|
337
|
+
| `POST /__openrouter-plus/targets/register` | `{ upstream, tier?, id? }` — record an endpoint without touching a provider |
|
|
338
|
+
| `POST /__openrouter-plus/targets/remove` | `{ id }` — refuse while a provider still routes through it |
|
|
339
|
+
|
|
340
|
+
`attach`/`detach` are what the Models-page switch calls; both are idempotent and report
|
|
341
|
+
`changed: false` when the row is already in the requested state. A rejected request answers `400`
|
|
342
|
+
with `{ error: { message } }` — for example a body that is not an http(s) URL, or one that points
|
|
343
|
+
back at the relay. `POST /tier` answers `400` for an unknown preset (`turbo`) or a missing
|
|
344
|
+
`session`, and accepts `preset: null` (or no `preset` at all) to clear. The tier-era body shape is still
|
|
345
|
+
read: `tier: "fast"` maps onto the `priority` preset, and any other legacy name maps onto the preset
|
|
346
|
+
that sends the same field.
|
|
347
|
+
|
|
348
|
+
Everything in `/tier` is deliberately write-free: a config write would re-apply this plugin row and
|
|
349
|
+
restart the listener, which is not something a mid-conversation click should risk. Only
|
|
350
|
+
`/tier/default` writes, because it is a preference rather than one Session's state.
|
|
351
|
+
|
|
352
|
+
`POST` requires `x-oorp-client: web`. CORS is reflected only for loopback GUI origins.
|
|
353
|
+
|
|
354
|
+
```bash
|
|
355
|
+
curl -s http://127.0.0.1:8791/__openrouter-plus/status | jq .metrics.byTier
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
## Configuration
|
|
359
|
+
|
|
360
|
+
`cordis.patch.yml` ships the defaults; override them in your profile's patch layer:
|
|
361
|
+
|
|
362
|
+
| Key | Default | Meaning |
|
|
363
|
+
| --- | --- | --- |
|
|
364
|
+
| `enabled` | `true` | `false` keeps the plugin installed but binds nothing |
|
|
365
|
+
| `host` | `127.0.0.1` | bind address — loopback only, deliberately |
|
|
366
|
+
| `port` | `8791` | preferred port |
|
|
367
|
+
| `portFallback` | `true` | fall forward to the next free port (the page probes 8791–8794) |
|
|
368
|
+
| `upstream` | `https://openrouter.ai/api/v1` | where requests are forwarded, no trailing slash |
|
|
369
|
+
| `routes` | tier mounts | mount path → tier, for pinned URLs (`/flex/v1`); `null` tier is a pure passthrough. Accepted names are `default`, `flex`, `priority`, `ultrafast`, and the aliases `auto`/`scale` (both mean *no pin*) and `fast` (= `priority`). The per-session design needs only `/v1` |
|
|
370
|
+
| `upstreamTimeoutMs` | `0` | `0` disables the upstream timeout (long SSE completions) |
|
|
371
|
+
| `reasoningEfforts` | `false`-able map | levels written onto relay routes' models; `false` disables, omit for evidence-only |
|
|
372
|
+
| `autoRepairReasoning` | boolean, default `true` | re-apply that fill whenever the profile changes, so editing a route's models cannot silently strip the effort selector |
|
|
373
|
+
| `defaultPreset` | `none` | the preset an unchosen Session inherits; `none` pins nothing. A tier-era `defaultTier` is read forward onto the equivalent preset. Written by **Service tier for new sessions** on the settings page |
|
|
374
|
+
| `targets` | `{}` | extra endpoints served from `/t/<id>/…` mounts: `{ "<id>": { upstream } }`. Written by the switch; edit by hand only if you want one without a provider row |
|
|
375
|
+
|
|
376
|
+
## Security model
|
|
377
|
+
|
|
378
|
+
- **Loopback only.** The relay forwards the caller's `Authorization` verbatim and stores no key.
|
|
379
|
+
Do not bind a routable interface.
|
|
380
|
+
- **Read-only telemetry.** The status API exposes counts and token sums, never prompts or keys.
|
|
381
|
+
- **Reversible by construction.** The plugin only ever adds a provider route whose `baseURL` points
|
|
382
|
+
at its own origin, and only removes routes that pass that same test. The switch is the same idea
|
|
383
|
+
one level down: the endpoint it took over is recorded in the plugin's config, and **Detach** —
|
|
384
|
+
or deleting the target — puts it back, so a provider is never left pointing at a dead mount.
|
|
385
|
+
|
|
386
|
+
## Troubleshooting
|
|
387
|
+
|
|
388
|
+
| Symptom | Cause and fix |
|
|
389
|
+
| --- | --- |
|
|
390
|
+
| Card says *relay offline* | The port range is busy, or the row is `enabled: false`. Check the Host log for `[openrouter-plus]`; set a fixed `port`. |
|
|
391
|
+
| A model errors after disabling the plugin | That model belongs to a route pointing at the relay. Re-enable the plugin or remove the route on the OpenRouter Plus page. |
|
|
392
|
+
| Requests work but nothing is steered | The Session is on **No pin** (or the profile default is), the mount is a bare `/v1` passthrough, or the request is not `POST` with a JSON body. |
|
|
393
|
+
| The chip shows a preset, but another Session still bills full price | Every preset is per Session by design. Set it in that Session's composer, or set a profile **Service tier for new sessions**. |
|
|
394
|
+
| The chip disappeared after a Host restart | Choices are runtime state and the memory table is dropped on restart (and on a default-preset write, which reloads the plugin). Re-pick it, or set the profile default. |
|
|
395
|
+
| `Flex` still bills full price | The provider decides which endpoint served; a preset only decides which ones may be asked. Confirm the served endpoint with `usage` or the OpenRouter dashboard. |
|
|
396
|
+
| `Fastest · any price` did not get faster | The comparator only reorders the pool the tier admitted. On a closed-weight model the fastest endpoints are priority-only, so use **Priority** — or compare against the `:nitro` slug in the OpenRouter dashboard. |
|
|
397
|
+
| `Ultra` behaves like `Priority` | `ultrafast` only holds on OpenAI models that receive a `max_tokens` limit; without one the API degrades to priority. The relay never invents a limit, so set one on the model row. |
|
|
398
|
+
| The page says *failed to render*, and switching the chip says *the relay did not confirm the change* | The tab is running the browser half from before the update against the restarted Host. Reload the page (or reopen the app window); only a document reload re-fetches the client bundle, and a Host restart alone keeps the old module in memory. |
|
|
399
|
+
| No effort selector for a relay route's model | The model has no `reasoningEfforts` — usually because the model list was edited in Settings → Models afterwards. It is restored automatically; if `autoRepairReasoning` is off, press **Add reasoning to N models** on the OpenRouter Plus page. See "Reasoning effort on a relay route". |
|
|
400
|
+
|
|
401
|
+
## Uninstall
|
|
402
|
+
|
|
403
|
+
Remove the route(s) in Settings → Models, then:
|
|
404
|
+
|
|
405
|
+
```bash
|
|
406
|
+
dsh plugin --profile web remove dsh-openrouter-plus
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
## Development
|
|
410
|
+
|
|
411
|
+
```bash
|
|
412
|
+
node --check index.js && node --check client.js
|
|
413
|
+
node --test # 99 tests, no network, no profile writes
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the module contract, and
|
|
417
|
+
`docs/DESIGN.md` for the design rationale against the upstream discussion.
|
|
418
|
+
|
|
419
|
+
MIT.
|
|
420
|
+
|
|
421
|
+
[](https://github.com/deepseek-ai/deepseek-harness)
|