@maci0/dsh-omniroute 0.0.0-stage → 0.8.2
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/LICENSE +21 -0
- package/README.md +226 -2
- package/cordis.patch.yml +10 -0
- package/icon.svg +8 -0
- package/lib/client.js +383 -0
- package/lib/host.js +12 -0
- package/lib/index.js +403 -0
- package/lib/omniroute.js +206 -0
- package/lib/types/host.d.ts +112 -0
- package/lib/types/index.d.ts +111 -0
- package/lib/types/omniroute.d.ts +159 -0
- package/lib/types/util.d.ts +15 -0
- package/lib/util.js +24 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +93 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-omniroute 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,227 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-omniroute
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
OmniRoute's own API, inside DeepSeek Harness. The router that fronts every
|
|
4
|
+
upstream account knows things the OpenAI-compatible `/v1` surface does not: which
|
|
5
|
+
models it can serve *right now*, which accounts it holds, and what each of those
|
|
6
|
+
accounts has left. This plugin serves all three as JSON routes on the harness's
|
|
7
|
+
own origin, behind the same trust fence as the harness's browser routes, with the
|
|
8
|
+
OmniRoute API key never leaving the host process.
|
|
9
|
+
|
|
10
|
+
## What you get
|
|
11
|
+
|
|
12
|
+
- **The live catalog, not `/v1/models`.** `available` and `vision` per
|
|
13
|
+
model, with `?available=1`, `?q=`, and `?provider=` to narrow it.
|
|
14
|
+
- **The account inventory.** Every upstream connection with its id, provider, switch
|
|
15
|
+
state, and whether it publishes quota.
|
|
16
|
+
- **Per-connection quota.** Plan and windows for each connection that publishes one.
|
|
17
|
+
- **An explicit picker sync.** `?sync=1` merges the available models into a provider
|
|
18
|
+
route's settings, so the model picker learns them. It never runs on a timer.
|
|
19
|
+
- **The key stays on the host.** Routes answer formatted JSON only.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
> **Install it as a bundle.** `dsh plugin add …` mounts the row from the
|
|
24
|
+
> package's own patch layer, which is what the settings editor can write to. A
|
|
25
|
+
> row added with `--patch` is an overlay: it disappears at the next start, and
|
|
26
|
+
> the Plugins card cannot save into it (the editor refuses a write an overlay
|
|
27
|
+
> would win).
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
dsh plugin --profile web add @maci0/dsh-omniroute@0.8.2
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
This installs the public npm package; no GitHub token or `~/.secrets` setup is needed.
|
|
34
|
+
The version is pinned. To upgrade, run the same command with a newer version,
|
|
35
|
+
then restart `dsh web` (bundle layers compose at boot).
|
|
36
|
+
|
|
37
|
+
Do **not** also paste the `id: omniroute` row from `cordis.patch.yml` into the
|
|
38
|
+
profile's own patch: `insert` does not dedupe ids.
|
|
39
|
+
|
|
40
|
+
## Configure
|
|
41
|
+
|
|
42
|
+
Every field is editable from the Web client: open **Plugins**, then the
|
|
43
|
+
**omniroute** row, then **Configure**. The card validates the URL, the non-empty
|
|
44
|
+
references, and the numeric bounds before the write, saves every changed field
|
|
45
|
+
in one update, and offers **Reset to defaults** for the fields you overrode.
|
|
46
|
+
Every field is `volatile()`, so a save reaches the running routes: each is read
|
|
47
|
+
per request and a settings write drops the served readings, so a new origin or
|
|
48
|
+
cache window applies to the next poll instead of a restart.
|
|
49
|
+
|
|
50
|
+
The same row can be set by hand in the profile's own `cordis.patch.yml`; a patch
|
|
51
|
+
replaces the targeted row's whole `config`, so restate every key you keep:
|
|
52
|
+
|
|
53
|
+
```yaml
|
|
54
|
+
- id: omniroute
|
|
55
|
+
config:
|
|
56
|
+
baseURL: http://192.168.0.100:20128/v1 # origin is used; the /v1 path is dropped
|
|
57
|
+
apiKeyEnv: OMNIROUTE_API_KEY # credential reference, then that env var
|
|
58
|
+
timeoutMs: 10000 # per OmniRoute request
|
|
59
|
+
cacheSeconds: 30 # how long one reading is served from cache
|
|
60
|
+
syncNamespace: llm-pi-ai # where ?sync=1 writes
|
|
61
|
+
syncProvider: omniroute # which provider route it writes for
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
| Key | Default | Bounds | Meaning |
|
|
65
|
+
|---|---|---|---|
|
|
66
|
+
| `baseURL` | `http://localhost:20128` | absolute http(s) | The deployment. Its origin addresses OmniRoute's management API, so a `/v1` suffix is fine and ignored. A value without an http(s) scheme fails at load, and a live edit to one makes every route answer a configuration error without asking anything; no default origin is guessed. |
|
|
67
|
+
| `apiKeyEnv` | `OMNIROUTE_API_KEY` | non-empty | Credential reference resolved through `ctx.credentials`, falling back to the launcher's environment. The key needs the scopes the endpoints use: this box's key carries `self:usage` and `manage`. |
|
|
68
|
+
| `timeoutMs` | `10000` | 1–60000 | Deadline for each OmniRoute request, including the connection listing. |
|
|
69
|
+
| `cacheSeconds` | `30` | 0–3600 | How long one reading is served before OmniRoute is asked again. `0` re-asks every time. |
|
|
70
|
+
| `syncNamespace` | `llm-pi-ai` | non-empty | Settings namespace `?sync=1` merges into. |
|
|
71
|
+
| `syncProvider` | `omniroute` | non-empty | Provider route inside that namespace. |
|
|
72
|
+
|
|
73
|
+
## Routes
|
|
74
|
+
|
|
75
|
+
- `GET /omniroute/models`: the live catalog, with `available` and `vision` per
|
|
76
|
+
model, and `?available=1`, `?q=<text>`, `?provider=<key>` to narrow it.
|
|
77
|
+
`?sync=1` also copies the available models into a provider route's settings,
|
|
78
|
+
which is how the model picker learns them.
|
|
79
|
+
- `GET /omniroute/connections`: every upstream connection, with the id, the
|
|
80
|
+
provider, whether it is switched on, and whether it publishes quota.
|
|
81
|
+
- `GET /omniroute/quota`: the plan and windows of every connection that
|
|
82
|
+
publishes one.
|
|
83
|
+
|
|
84
|
+
Every route also answers `?refresh=1`, which ignores the cache for that read.
|
|
85
|
+
`fetchedAt` is when OmniRoute answered, so a reading served from cache keeps the
|
|
86
|
+
time it was taken.
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
http://127.0.0.1:3080/omniroute/models?available=1&q=claude
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`/omniroute/models` answers with the catalog and its counts:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"status": "ok",
|
|
97
|
+
"provider": "omniroute",
|
|
98
|
+
"origin": "http://192.168.0.100:20128",
|
|
99
|
+
"fetchedAt": 1789793193906,
|
|
100
|
+
"counts": { "total": 525, "available": 95, "shown": 95 },
|
|
101
|
+
"models": [
|
|
102
|
+
{ "id": "cc/claude-opus-5", "provider": "cc", "name": "Claude Opus 5", "available": true, "vision": true }
|
|
103
|
+
]
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`/omniroute/quota` answers with the connection list and one entry per window:
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"status": "ok",
|
|
112
|
+
"windows": [
|
|
113
|
+
{
|
|
114
|
+
"connection": "acbe586b-7488-44c4-b8d1-de4982c18ed7",
|
|
115
|
+
"provider": "deepseek",
|
|
116
|
+
"plan": "DeepSeek",
|
|
117
|
+
"window": "credits_usd",
|
|
118
|
+
"used": 0,
|
|
119
|
+
"total": 0,
|
|
120
|
+
"remaining": 91.27,
|
|
121
|
+
"remainingPercent": 100,
|
|
122
|
+
"unlimited": true,
|
|
123
|
+
"currency": "USD",
|
|
124
|
+
"limitReached": false
|
|
125
|
+
}
|
|
126
|
+
],
|
|
127
|
+
"limitReached": []
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Filling the model picker
|
|
132
|
+
|
|
133
|
+
The picker reads a route's models from `settings.yaml`, so the live catalog only
|
|
134
|
+
reaches it when something writes them there. That is what `?sync=1` does, and it
|
|
135
|
+
is deliberately explicit rather than automatic: a background writer that
|
|
136
|
+
rewrites a provider row on a timer is not something a plugin should do to a
|
|
137
|
+
settings document it does not own.
|
|
138
|
+
|
|
139
|
+
```sh
|
|
140
|
+
curl 'http://127.0.0.1:3080/omniroute/models?available=1&sync=1'
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
That fetches the catalog, keeps the `available` models (after `?q=` and
|
|
144
|
+
`?provider=`, so a narrowed sync is possible), and merges
|
|
145
|
+
`providers.<syncProvider>.models` into `syncNamespace`. The reply names what it
|
|
146
|
+
wrote:
|
|
147
|
+
|
|
148
|
+
```json
|
|
149
|
+
"synced": { "namespace": "llm-pi-ai", "path": "providers.omniroute.models", "count": 95 }
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The write is a merge, so fields the row already carries (`apiKeyEnv`, `baseURL`,
|
|
153
|
+
`compat`) survive; only `models` is replaced.
|
|
154
|
+
|
|
155
|
+
## How it works
|
|
156
|
+
|
|
157
|
+
- **The endpoints are OmniRoute's own.** They were read off a running `v3.8.50`
|
|
158
|
+
server rather than guessed: `GET /api/openapi/spec` lists 361 endpoints, and
|
|
159
|
+
`GET /api/agent-skills` indexes them per area. The paths people commonly try
|
|
160
|
+
(`/api/balance`, `/api/usage`, `/api/quota`, `/key/info`) are not among them
|
|
161
|
+
(`/key/info` is the web app's HTML fallback), which is why earlier attempts at
|
|
162
|
+
this router found nothing.
|
|
163
|
+
- **The catalog comes from `/api/models`, not `/v1/models`.** Both answer, but
|
|
164
|
+
only the management listing carries `available` and `supportsVision`, and only
|
|
165
|
+
it says which models are servable now instead of which have ever existed.
|
|
166
|
+
- **Quota is per connection, because OmniRoute holds the accounts.** The router
|
|
167
|
+
publishes no figure for itself, so the plugin lists connections, skips the ones
|
|
168
|
+
that are switched off or hide their quota, and asks `/api/usage/<id>` for the
|
|
169
|
+
rest. A connection that fails to answer contributes no window instead of
|
|
170
|
+
failing the read. `/api/quota/plans` resolves plans without consumption and
|
|
171
|
+
`/api/quota/pools` is empty unless the deployment defines pools, so neither is
|
|
172
|
+
read here.
|
|
173
|
+
- **Readings are cached** for `cacheSeconds`: the catalog, and the connection
|
|
174
|
+
listing together with its windows (both connection routes serve that one
|
|
175
|
+
reading, so they never disagree). Concurrent callers share one in-flight read,
|
|
176
|
+
so a refresh loop cannot multiply requests to the router.
|
|
177
|
+
- **Every route checks the trust fence first.** The plugin injects the
|
|
178
|
+
composition's `connection` service and asks `requestRejection` before anything
|
|
179
|
+
else; a composition without that service never mounts the routes.
|
|
180
|
+
- **The key stays in this process.** Routes answer formatted JSON only: no key,
|
|
181
|
+
no Authorization header. A refusal is this plugin's own one-line message
|
|
182
|
+
(HTTP status, timeout, unreachable, non-JSON body), never the router's body;
|
|
183
|
+
a failure inside another service (credentials, settings) is logged on the
|
|
184
|
+
host and answered generically.
|
|
185
|
+
|
|
186
|
+
## Limits
|
|
187
|
+
|
|
188
|
+
- **One quota read is one listing plus one request per visible connection.** On
|
|
189
|
+
the reference box that is 21 requests, cached for `cacheSeconds`. A deployment
|
|
190
|
+
with many connections should raise `cacheSeconds`.
|
|
191
|
+
- **Quota is read per connection, not per route.** A route served by OmniRoute
|
|
192
|
+
spends whichever account the router picks, so no single figure belongs to it.
|
|
193
|
+
`dsh-quota-check`'s OmniRoute probe renders the fullest window across
|
|
194
|
+
connections for exactly that reason.
|
|
195
|
+
- **No statusbar chip.** The browser half ships the row's configuration card
|
|
196
|
+
only; the routes are the read surface, and `dsh-quota-check` draws the chip.
|
|
197
|
+
- **The catalog carries no capacities.** `/api/models` reports availability and
|
|
198
|
+
vision but no context window, so a synced model entry sets only `id` and
|
|
199
|
+
`name`. Set capacities in the provider row when they matter.
|
|
200
|
+
- **`?sync=1` writes a namespace this plugin does not own.** It merges into
|
|
201
|
+
`syncNamespace` (default `llm-pi-ai`) because that is where the picker reads
|
|
202
|
+
from. Point it elsewhere, or leave it alone, when that route is not yours.
|
|
203
|
+
- **Read-only.** Creating keys, connections, and combos is out of scope; nothing
|
|
204
|
+
here mutates the router.
|
|
205
|
+
|
|
206
|
+
## Development
|
|
207
|
+
|
|
208
|
+
dsh loads plugins on Node `^22.19.0 || >=24.0.0`; development and tests run on bun.
|
|
209
|
+
|
|
210
|
+
```sh
|
|
211
|
+
bun install # first run only (typescript, @types/node, cordis, carrier, schemastery)
|
|
212
|
+
bun run typecheck
|
|
213
|
+
bun test # parsers, the client, both route halves, and a real-composition boot
|
|
214
|
+
bun run build # tsc -> lib/*.js
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
For local development, `dsh plugin --profile <name> add <path-to-checkout>`
|
|
218
|
+
(after `bun run build`), then restart `dsh web`.
|
|
219
|
+
|
|
220
|
+
`bun test` includes the real-composition case: the plugin mounts into a real
|
|
221
|
+
Cordis `Context` beside the real HTTP carrier on an OS-assigned port, a route is
|
|
222
|
+
driven over real HTTP with the settings write observed, disposing the fiber
|
|
223
|
+
must withdraw every route, and no route exists until the trust fence is mounted.
|
|
224
|
+
|
|
225
|
+
## Licence
|
|
226
|
+
|
|
227
|
+
MIT. See [LICENSE](LICENSE).
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# The dsh-omniroute bundle patch: applied automatically when a profile lists
|
|
2
|
+
# this bundle (`dsh plugin add`/`update` appends the package to
|
|
3
|
+
# dsh.profile.bundles). Override the row from the profile's own
|
|
4
|
+
# cordis.patch.yml with a `- id: omniroute` row, which replaces the row's whole
|
|
5
|
+
# `config`.
|
|
6
|
+
# Do not also insert this same row into the profile patch: insert does not
|
|
7
|
+
# dedupe ids, and a second row would register the plugin twice.
|
|
8
|
+
- insert:
|
|
9
|
+
- id: omniroute
|
|
10
|
+
name: '@maci0/dsh-omniroute'
|
package/icon.svg
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<path d="M15.2 16.2L10.2 11.6M20.8 16.2L25.8 11.6M15.2 19.8L10.2 24.4M20.8 19.8L25.8 24.4" stroke="#145AF3" stroke-width="1.6" stroke-linecap="round"/>
|
|
3
|
+
<circle cx="18" cy="18" r="4" fill="#145AF3"/>
|
|
4
|
+
<circle cx="8" cy="9.5" r="2.4" fill="#7CB7FF"/>
|
|
5
|
+
<circle cx="28" cy="9.5" r="2.4" fill="#45D9E7"/>
|
|
6
|
+
<circle cx="8" cy="26.5" r="2.4" fill="#F2AF63"/>
|
|
7
|
+
<circle cx="28" cy="26.5" r="2.4" fill="#7CB7FF"/>
|
|
8
|
+
</svg>
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-omniroute browser half: the OmniRoute card on the Plugins page.
|
|
3
|
+
*
|
|
4
|
+
* One surface: the OmniRoute card on the Plugins page, keyed on the
|
|
5
|
+
* `omniroute` settings namespace the host half declares as its row id. Every
|
|
6
|
+
* field of the row is volatile, and the host reads it per request, so a save
|
|
7
|
+
* lands on the next poll without remounting the plugin or restarting the
|
|
8
|
+
* server.
|
|
9
|
+
*
|
|
10
|
+
* The form stages edits locally and writes them in one `scope.mutate` call:
|
|
11
|
+
* a half-typed base URL must not reach the settings document field by field.
|
|
12
|
+
* Chrome is a stylesheet, not inline style objects: the module system claims
|
|
13
|
+
* every `<style>` tag a factory appends while it materializes and removes it
|
|
14
|
+
* when the package unloads. Classes are `or-`-prefixed because that sheet
|
|
15
|
+
* lands in the page's own document.
|
|
16
|
+
*
|
|
17
|
+
* This file is plain JavaScript on purpose. The client module system serves a
|
|
18
|
+
* package's `exports["./client"]` artifact as a lazy-CJS factory registered on
|
|
19
|
+
* `window.__ModuleLoader__`, and that is the whole format: an out-of-tree
|
|
20
|
+
* plugin can author it directly instead of reproducing the repository's tsdown
|
|
21
|
+
* client preset. `react` is provided by the module system; nothing else is
|
|
22
|
+
* required here.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
window.__ModuleLoader__.load({
|
|
26
|
+
id: '@maci0/dsh-omniroute',
|
|
27
|
+
|
|
28
|
+
factory: (require) => {
|
|
29
|
+
var module = { exports: {} }
|
|
30
|
+
var exports = module.exports
|
|
31
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
32
|
+
|
|
33
|
+
const React = require('react')
|
|
34
|
+
|
|
35
|
+
/** Settings namespace shared with the host half; also this card's slot key. */
|
|
36
|
+
const NAMESPACE = 'omniroute'
|
|
37
|
+
|
|
38
|
+
/** Locale namespace for this plugin's copy. */
|
|
39
|
+
const LOCALE_NS = 'omniroute'
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Every editable row field, in display order. `integer` fields are sent as
|
|
43
|
+
* numbers and must be positive integers; the host schema bounds each one,
|
|
44
|
+
* and the card refuses what it can see before the write.
|
|
45
|
+
*/
|
|
46
|
+
const FIELDS = [
|
|
47
|
+
{ key: 'baseURL', kind: 'url', label: 'labelBaseURL', hint: 'hintBaseURL' },
|
|
48
|
+
{ key: 'apiKeyEnv', kind: 'text', label: 'labelApiKeyEnv', hint: 'hintApiKeyEnv' },
|
|
49
|
+
{ key: 'timeoutMs', kind: 'integer', min: 1, max: 60_000, label: 'labelTimeoutMs', hint: 'hintTimeoutMs' },
|
|
50
|
+
{ key: 'cacheSeconds', kind: 'integer', min: 0, max: 3_600, label: 'labelCacheSeconds', hint: 'hintCacheSeconds' },
|
|
51
|
+
{ key: 'syncNamespace', kind: 'text', label: 'labelSyncNamespace', hint: 'hintSyncNamespace' },
|
|
52
|
+
{ key: 'syncProvider', kind: 'text', label: 'labelSyncProvider', hint: 'hintSyncProvider' },
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
/** Every class is `or-`-prefixed: the sheet lands in the page's own document. */
|
|
56
|
+
const CSS = [
|
|
57
|
+
'.or-page{display:flex;flex-direction:column;gap:12px}',
|
|
58
|
+
'.or-field{display:flex;flex-direction:column;gap:4px}',
|
|
59
|
+
'.or-label{font-size:13px;font-weight:600;line-height:1.5;color:var(--dsw-alias-label-primary)}',
|
|
60
|
+
'.or-hint{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}',
|
|
61
|
+
'.or-input{font:inherit;font-size:13px;line-height:1.5;padding:5px 12px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-4);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;width:100%;box-sizing:border-box}',
|
|
62
|
+
'.or-input:disabled{cursor:default;opacity:.5}',
|
|
63
|
+
'.or-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px}',
|
|
64
|
+
'.or-button{appearance:none;font:inherit;font-size:13px;line-height:1.5;padding:5px 14px;cursor:pointer;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-4);border:1px solid var(--dsw-alias-border-l2);border-radius:8px}',
|
|
65
|
+
'.or-button:disabled{cursor:default;opacity:.5}',
|
|
66
|
+
'.or-button-quiet{padding:3px 10px;font-size:12px;color:var(--dsw-alias-label-secondary)}',
|
|
67
|
+
'.or-status{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}',
|
|
68
|
+
'.or-error{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}',
|
|
69
|
+
].join('')
|
|
70
|
+
|
|
71
|
+
// Appended while the factory materializes: the module system claims the tag
|
|
72
|
+
// for this package and disposes it on unload. Guarded because the unit
|
|
73
|
+
// tests evaluate this file without a DOM.
|
|
74
|
+
if (typeof document !== 'undefined') {
|
|
75
|
+
const style = document.createElement('style')
|
|
76
|
+
style.textContent = CSS
|
|
77
|
+
document.head.append(style)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Plugin version, shown in the card footer. Kept in lockstep with package.json. */
|
|
81
|
+
const VERSION = '0.8.2'
|
|
82
|
+
|
|
83
|
+
const en = {
|
|
84
|
+
title: 'OmniRoute',
|
|
85
|
+
summary: 'OmniRoute catalog, connections, and quota at {baseURL}.',
|
|
86
|
+
summaryEmpty: 'OmniRoute catalog, connections, and quota.',
|
|
87
|
+
labelBaseURL: 'Base URL',
|
|
88
|
+
labelApiKeyEnv: 'API key reference',
|
|
89
|
+
labelTimeoutMs: 'Request deadline (ms)',
|
|
90
|
+
labelCacheSeconds: 'Cache seconds',
|
|
91
|
+
labelSyncNamespace: 'Sync namespace',
|
|
92
|
+
labelSyncProvider: 'Sync provider',
|
|
93
|
+
hintBaseURL: 'OmniRoute origin, with or without its /v1 path.',
|
|
94
|
+
hintApiKeyEnv: 'Credential reference holding the API key; the key itself never reaches the browser.',
|
|
95
|
+
hintTimeoutMs: 'Per-request deadline for one OmniRoute call.',
|
|
96
|
+
hintCacheSeconds: 'How long a reading is served before OmniRoute is asked again. 0 asks every time.',
|
|
97
|
+
hintSyncNamespace: 'Profile entry id ?sync=1 writes the model catalog into.',
|
|
98
|
+
hintSyncProvider: 'Provider route ?sync=1 writes the model catalog into.',
|
|
99
|
+
overridden: 'overridden',
|
|
100
|
+
save: 'Save',
|
|
101
|
+
saving: 'Saving…',
|
|
102
|
+
saved: 'Saved. The next poll uses these values.',
|
|
103
|
+
reset: 'Reset to defaults',
|
|
104
|
+
persists: 'Stored in your profile; every route reads the row on each request.',
|
|
105
|
+
readOnly: 'Read-only: this deployment does not persist settings.',
|
|
106
|
+
baseUrlInvalid: 'Base URL must be an absolute http(s) URL.',
|
|
107
|
+
textInvalid: 'Must not be empty.',
|
|
108
|
+
numberInvalid: 'Must be a whole number within the allowed range.',
|
|
109
|
+
rejected: 'The Host refused the write; the previous values are still in effect.',
|
|
110
|
+
failed: 'Could not save: {message}',
|
|
111
|
+
version: 'v{version}',
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const zh = {
|
|
115
|
+
title: 'OmniRoute',
|
|
116
|
+
summary: 'OmniRoute 目录、连接与额度:{baseURL}。',
|
|
117
|
+
summaryEmpty: 'OmniRoute 目录、连接与额度。',
|
|
118
|
+
labelBaseURL: '基础地址',
|
|
119
|
+
labelApiKeyEnv: 'API 密钥引用',
|
|
120
|
+
labelTimeoutMs: '请求超时(毫秒)',
|
|
121
|
+
labelCacheSeconds: '缓存秒数',
|
|
122
|
+
labelSyncNamespace: '同步命名空间',
|
|
123
|
+
labelSyncProvider: '同步提供方',
|
|
124
|
+
hintBaseURL: 'OmniRoute 地址,可带或不带 /v1 路径。',
|
|
125
|
+
hintApiKeyEnv: '保存 API 密钥的凭据引用;密钥本身不会进入浏览器。',
|
|
126
|
+
hintTimeoutMs: '单次 OmniRoute 调用的超时时间。',
|
|
127
|
+
hintCacheSeconds: '一次读取在再次询问 OmniRoute 前可复用的时长。0 表示每次都询问。',
|
|
128
|
+
hintSyncNamespace: '?sync=1 写入模型目录的配置条目 id。',
|
|
129
|
+
hintSyncProvider: '?sync=1 写入模型目录的提供方路由。',
|
|
130
|
+
overridden: '已覆盖',
|
|
131
|
+
save: '保存',
|
|
132
|
+
saving: '保存中…',
|
|
133
|
+
saved: '已保存。下一次轮询将使用这些值。',
|
|
134
|
+
reset: '恢复默认',
|
|
135
|
+
persists: '保存在你的配置中;每个路由都会在每次请求时读取该行。',
|
|
136
|
+
readOnly: '只读:此部署不持久化设置。',
|
|
137
|
+
baseUrlInvalid: '基础地址必须是绝对的 http(s) URL。',
|
|
138
|
+
textInvalid: '不能为空。',
|
|
139
|
+
numberInvalid: '必须是允许范围内的整数。',
|
|
140
|
+
rejected: 'Host 拒绝了写入;原先的值仍然有效。',
|
|
141
|
+
failed: '保存失败:{message}',
|
|
142
|
+
version: 'v{version}',
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Bind one settings scope to a React subscription.
|
|
147
|
+
* @param scope - the scope bound to the omniroute settings namespace.
|
|
148
|
+
* @returns a hook reading that scope's current snapshot.
|
|
149
|
+
*/
|
|
150
|
+
function useScope(scope) {
|
|
151
|
+
const subscribe = (listener) => scope.subscribe(listener)
|
|
152
|
+
const getSnapshot = () => scope.getSnapshot()
|
|
153
|
+
return () => React.useSyncExternalStore(subscribe, getSnapshot)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Read a snapshot's resolved row. A namespace this deployment does not
|
|
158
|
+
* serve reports no row, which the card renders as nothing at all.
|
|
159
|
+
* @param snapshot - the settings scope snapshot.
|
|
160
|
+
* @returns the resolved row, or `undefined` when unreadable.
|
|
161
|
+
*/
|
|
162
|
+
function rowOf(snapshot) {
|
|
163
|
+
if (snapshot.status !== 'ready') return undefined
|
|
164
|
+
return snapshot.value !== null && typeof snapshot.value === 'object' ? snapshot.value : {}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** One editable field: label, hint, input, and the override marker. */
|
|
168
|
+
function Field(props) {
|
|
169
|
+
const { t, field, value, overridden, disabled, onChange } = props
|
|
170
|
+
return React.createElement(
|
|
171
|
+
'div',
|
|
172
|
+
{ className: 'or-field' },
|
|
173
|
+
React.createElement(
|
|
174
|
+
'div',
|
|
175
|
+
{ className: 'or-row' },
|
|
176
|
+
React.createElement('span', { className: 'or-label' }, t(field.label)),
|
|
177
|
+
overridden === true
|
|
178
|
+
? React.createElement('span', { className: 'or-hint' }, `(${t('overridden')})`)
|
|
179
|
+
: null,
|
|
180
|
+
),
|
|
181
|
+
React.createElement('input', {
|
|
182
|
+
className: 'or-input',
|
|
183
|
+
type: field.kind === 'integer' ? 'number' : 'text',
|
|
184
|
+
value: value,
|
|
185
|
+
disabled,
|
|
186
|
+
'aria-label': t(field.label),
|
|
187
|
+
onChange: (event) => { onChange(field.key, event.target.value) },
|
|
188
|
+
}),
|
|
189
|
+
React.createElement('span', { className: 'or-hint' }, t(field.hint)),
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Build the card component over one bound settings scope.
|
|
195
|
+
* @param scope - the scope bound to the omniroute settings namespace.
|
|
196
|
+
* @param t - translate function bound to this plugin's locale namespace.
|
|
197
|
+
* @returns the component the slot renders.
|
|
198
|
+
*/
|
|
199
|
+
function createCard(scope, t) {
|
|
200
|
+
const useOmniRoute = useScope(scope)
|
|
201
|
+
|
|
202
|
+
return function OmniRouteCard(props) {
|
|
203
|
+
const snapshot = useOmniRoute()
|
|
204
|
+
const [draft, setDraft] = React.useState(null)
|
|
205
|
+
const [error, setError] = React.useState(null)
|
|
206
|
+
const [status, setStatus] = React.useState(null)
|
|
207
|
+
// True while a write is in flight: Save and Reset stay disabled, so a
|
|
208
|
+
// double click sends one write.
|
|
209
|
+
const [pending, setPending] = React.useState(false)
|
|
210
|
+
|
|
211
|
+
const row = rowOf(snapshot)
|
|
212
|
+
// A namespace this deployment does not serve renders no trace of itself.
|
|
213
|
+
if (row === undefined) return null
|
|
214
|
+
|
|
215
|
+
if (props != null && props.view === 'summary') {
|
|
216
|
+
return row.baseURL === undefined
|
|
217
|
+
? t('summaryEmpty')
|
|
218
|
+
: t('summary', { baseURL: String(row.baseURL) })
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const disabled = !snapshot.writable
|
|
222
|
+
const busy = disabled || pending
|
|
223
|
+
const shown = draft ?? row
|
|
224
|
+
const user = snapshot.user !== null && typeof snapshot.user === 'object' ? snapshot.user : {}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Validate and collect the staged edits.
|
|
228
|
+
* @returns an error key, or the ordered path operations to write.
|
|
229
|
+
*/
|
|
230
|
+
const collect = () => {
|
|
231
|
+
const ops = []
|
|
232
|
+
for (const field of FIELDS) {
|
|
233
|
+
const value = shown[field.key]
|
|
234
|
+
if (String(value) === String(row[field.key])) continue
|
|
235
|
+
if (field.kind === 'integer') {
|
|
236
|
+
// An emptied box is not zero: `Number('')` is 0, which would pass
|
|
237
|
+
// a min of 0 and write a value nobody typed.
|
|
238
|
+
const text = String(value).trim()
|
|
239
|
+
const parsed = Number(text)
|
|
240
|
+
if (text === '' || !Number.isInteger(parsed) || parsed < field.min || parsed > field.max) {
|
|
241
|
+
return { error: 'numberInvalid' }
|
|
242
|
+
}
|
|
243
|
+
ops.push({ op: 'set', path: [field.key], value: parsed })
|
|
244
|
+
continue
|
|
245
|
+
}
|
|
246
|
+
const text = String(value).trim()
|
|
247
|
+
if (text === '') return { error: 'textInvalid' }
|
|
248
|
+
if (field.kind === 'url' && !/^https?:\/\//u.test(text)) return { error: 'baseUrlInvalid' }
|
|
249
|
+
ops.push({ op: 'set', path: [field.key], value: text })
|
|
250
|
+
}
|
|
251
|
+
return { ops }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Run one settings write; a synchronous throw becomes a rejection. */
|
|
255
|
+
const write = (ops) => new Promise((resolve) => { resolve(scope.mutate(ops)) })
|
|
256
|
+
|
|
257
|
+
const save = () => {
|
|
258
|
+
if (pending) return
|
|
259
|
+
setError(null)
|
|
260
|
+
const result = collect()
|
|
261
|
+
if (result.error !== undefined) {
|
|
262
|
+
setError(t(result.error))
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
if (result.ops.length === 0) {
|
|
266
|
+
// Nothing moved: an edit that landed back on the stored value is
|
|
267
|
+
// not a change, and the row already holds it.
|
|
268
|
+
setStatus(null)
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
setStatus(t('saving'))
|
|
272
|
+
setPending(true)
|
|
273
|
+
write(result.ops)
|
|
274
|
+
.then((accepted) => {
|
|
275
|
+
if (accepted === false) {
|
|
276
|
+
setError(t('rejected'))
|
|
277
|
+
setStatus(null)
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
setDraft(null)
|
|
281
|
+
setStatus(t('saved'))
|
|
282
|
+
})
|
|
283
|
+
.catch((cause) => {
|
|
284
|
+
setStatus(null)
|
|
285
|
+
setError(t('failed', { message: cause instanceof Error ? cause.message : String(cause) }))
|
|
286
|
+
})
|
|
287
|
+
.finally(() => { setPending(false) })
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const reset = () => {
|
|
291
|
+
if (pending) return
|
|
292
|
+
setDraft(null)
|
|
293
|
+
setStatus(null)
|
|
294
|
+
setError(null)
|
|
295
|
+
const ops = Object.keys(user)
|
|
296
|
+
.filter((key) => FIELDS.some((field) => field.key === key))
|
|
297
|
+
.map((key) => ({ op: 'unset', path: [key] }))
|
|
298
|
+
if (ops.length === 0) return
|
|
299
|
+
setPending(true)
|
|
300
|
+
write(ops).then((accepted) => {
|
|
301
|
+
// The Host resolves false when it refuses the write, so a swallowed
|
|
302
|
+
// refusal would leave the override in place with nothing said.
|
|
303
|
+
if (accepted === false) setError(t('rejected'))
|
|
304
|
+
}).catch((cause) => {
|
|
305
|
+
setError(t('failed', { message: cause instanceof Error ? cause.message : String(cause) }))
|
|
306
|
+
}).finally(() => { setPending(false) })
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const overridden = Object.keys(user).some((key) => FIELDS.some((field) => field.key === key))
|
|
310
|
+
|
|
311
|
+
return React.createElement(
|
|
312
|
+
'div',
|
|
313
|
+
{ className: 'or-page' },
|
|
314
|
+
...FIELDS.map((field) => React.createElement(Field, {
|
|
315
|
+
key: field.key,
|
|
316
|
+
t,
|
|
317
|
+
field,
|
|
318
|
+
value: String(shown[field.key] ?? ''),
|
|
319
|
+
overridden: Object.hasOwn(user, field.key),
|
|
320
|
+
disabled: busy,
|
|
321
|
+
onChange: (key, value) => {
|
|
322
|
+
setStatus(null)
|
|
323
|
+
setDraft({ ...shown, [key]: value })
|
|
324
|
+
},
|
|
325
|
+
})),
|
|
326
|
+
React.createElement(
|
|
327
|
+
'div',
|
|
328
|
+
{ className: 'or-row' },
|
|
329
|
+
React.createElement(
|
|
330
|
+
'button',
|
|
331
|
+
{ type: 'button', className: 'or-button', disabled: busy, onClick: save },
|
|
332
|
+
t('save'),
|
|
333
|
+
),
|
|
334
|
+
overridden
|
|
335
|
+
? React.createElement(
|
|
336
|
+
'button',
|
|
337
|
+
{ type: 'button', className: 'or-button or-button-quiet', disabled: busy, onClick: reset },
|
|
338
|
+
t('reset'),
|
|
339
|
+
)
|
|
340
|
+
: null,
|
|
341
|
+
),
|
|
342
|
+
React.createElement(
|
|
343
|
+
'div',
|
|
344
|
+
{ className: 'or-status' },
|
|
345
|
+
status ?? (snapshot.writable ? t('persists') : t('readOnly')),
|
|
346
|
+
' ',
|
|
347
|
+
t('version', { version: VERSION }),
|
|
348
|
+
),
|
|
349
|
+
error === null ? null : React.createElement('div', { className: 'or-error' }, error),
|
|
350
|
+
)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Mount the browser surface: the OmniRoute card on the Plugins page.
|
|
356
|
+
* @param ctx - the browser plugin context.
|
|
357
|
+
*/
|
|
358
|
+
function apply(ctx) {
|
|
359
|
+
const t = ctx.locale.bind(LOCALE_NS)
|
|
360
|
+
ctx.effect(
|
|
361
|
+
() => ctx.locale.register(LOCALE_NS, { en, zh }),
|
|
362
|
+
'dsh-omniroute: locale dictionary',
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
const scope = ctx.configForms.get(NAMESPACE)
|
|
366
|
+
const Card = createCard(scope, t)
|
|
367
|
+
|
|
368
|
+
// The owner declares its own slot; injecting waits for it to exist, so
|
|
369
|
+
// this registration does not depend on plugin load order. The card takes
|
|
370
|
+
// no injected props (it closes over its own bound scope), so the entry
|
|
371
|
+
// declares the documented `locale` namespace and no `inject`.
|
|
372
|
+
ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
|
|
373
|
+
name: 'plugins.row.config',
|
|
374
|
+
key: '@maci0/dsh-omniroute#omniroute',
|
|
375
|
+
locale: LOCALE_NS,
|
|
376
|
+
}, Card))
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
exports.apply = apply
|
|
380
|
+
exports.inject = ['slots', 'configForms', 'locale']
|
|
381
|
+
return module.exports
|
|
382
|
+
},
|
|
383
|
+
})
|
package/lib/host.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The slice of the DeepSeek Harness host surface this plugin uses, declared
|
|
3
|
+
* structurally.
|
|
4
|
+
*
|
|
5
|
+
* The plugin installs from outside the harness checkout and has no runtime
|
|
6
|
+
* dependency on harness packages: the services it reaches are typed here, and
|
|
7
|
+
* a composition that mounts none of them simply omits that capability. The
|
|
8
|
+
* same shape also lets the unit tests drive `apply` with a plain fake context.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-omniroute/host
|
|
11
|
+
*/
|
|
12
|
+
export {};
|