omnigateway 0.2.2 → 0.4.0
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/README.md +506 -9
- package/bin/omni.js +3022 -163
- package/gateway.js +4094 -662
- package/package.json +1 -1
- package/public/assets/{Chip-DJwp7gBu.js → Chip-BltJSw8A.js} +5 -5
- package/public/assets/Confirm-gd8Y6snu.js +4 -0
- package/public/assets/CopyValue-Dds-Ow9a.js +27 -0
- package/public/assets/{Field-BDtp692L.js → Field-D-14HF0x.js} +11 -11
- package/public/assets/Lamp-BDf7-dMa.js +25 -0
- package/public/assets/Match-CgNxphvS.js +1 -0
- package/public/assets/Meter-stpt3vQu.js +14 -0
- package/public/assets/Modal-Dj3LvMS9.js +82 -0
- package/public/assets/Rack-B4c0dbIb.js +180 -0
- package/public/assets/{Readout-CEAoN5hu.js → Readout-Du15WYx0.js} +5 -5
- package/public/assets/{States-BEKLros0.js → States-CxLDlCaw.js} +5 -5
- package/public/assets/{Table-BBypbVWj.js → Table-CVDZ69Zx.js} +1 -1
- package/public/assets/Toggle-DMk46RX2.js +39 -0
- package/public/assets/TokenBreakdown-BySwFK6Q.js +31 -0
- package/public/assets/_app-Dx_xnlud.js +1 -0
- package/public/assets/_app.accounts-DvBYnbfS.js +64 -0
- package/public/assets/_app.console-CoaRdkB1.js +44 -0
- package/public/assets/_app.database-CES5ec5g.js +39 -0
- package/public/assets/_app.index-DwOZe1A-.js +62 -0
- package/public/assets/_app.keys-B5HrAu2U.js +76 -0
- package/public/assets/_app.logs-NbfIexz0.js +68 -0
- package/public/assets/_app.models-B1MdFRtQ.js +148 -0
- package/public/assets/_app.plugins._pluginId-ByMziW8p.js +20 -0
- package/public/assets/_app.settings-BkDkGkKF.js +46 -0
- package/public/assets/_app.usage-DNpM8Nub.js +83 -0
- package/public/assets/dist-9ps0Qd15.js +1 -0
- package/public/assets/index-gMv8Qs7m.js +178 -0
- package/public/assets/login-cCXnl34r.js +36 -0
- package/public/assets/matchContext-BOenO3J2.js +1 -0
- package/public/assets/plus-fXfUTt6_.js +1 -0
- package/public/assets/queries-CIOPbRcE.js +142 -0
- package/public/assets/reasons-Cb8yF4Oy.js +1 -0
- package/public/assets/shared-B4o-2quR.js +84 -0
- package/public/assets/{trash-2-CLYNAAJA.js → trash-2-ZPh3619D.js} +1 -1
- package/public/index.html +7 -2
- package/public/shared/chunks/jsx-runtime-CznXPbDH.js +1 -0
- package/public/shared/chunks/react-GitiQ0dt.js +1 -0
- package/public/shared/chunks/react-dom-BTlfKTOo.js +1 -0
- package/public/shared/chunks/rolldown-runtime-CbXtAM7H.js +1 -0
- package/public/shared/jsx-runtime.js +1 -0
- package/public/shared/react-dom-client.js +9 -0
- package/public/shared/react-dom.js +1 -0
- package/public/shared/react-query.js +1 -0
- package/public/shared/react.js +1 -0
- package/public/shared/styled-components.js +7 -0
- package/public/assets/Confirm-CVLz9F7a.js +0 -4
- package/public/assets/CopyValue-CNZbWS59.js +0 -27
- package/public/assets/Lamp-B65uWKbW.js +0 -25
- package/public/assets/Meter-BREBUFGX.js +0 -14
- package/public/assets/Modal-DbydSAlp.js +0 -82
- package/public/assets/Rack-Bbo4JfEX.js +0 -151
- package/public/assets/Toggle-BUr71O4J.js +0 -39
- package/public/assets/TokenBreakdown-C1ziK9d1.js +0 -31
- package/public/assets/_app-ByQ1bPbe.js +0 -1
- package/public/assets/_app.accounts-BTug_Xfo.js +0 -64
- package/public/assets/_app.console-DKyGL9xx.js +0 -44
- package/public/assets/_app.index-CSw9gmMf.js +0 -62
- package/public/assets/_app.keys-07Kbw4RS.js +0 -39
- package/public/assets/_app.logs-C9QiV39p.js +0 -32
- package/public/assets/_app.models-CDqVrAeF.js +0 -148
- package/public/assets/_app.settings-DmUEeTaP.js +0 -38
- package/public/assets/_app.usage-B1WuMGRr.js +0 -83
- package/public/assets/dist-Bka7ErcH.js +0 -1
- package/public/assets/index-DJfb1i4P.js +0 -186
- package/public/assets/login-DKZYqRh0.js +0 -33
- package/public/assets/queries-D8LJNQpL.js +0 -144
- package/public/assets/shared-CIhZ6FCO.js +0 -84
package/README.md
CHANGED
|
@@ -39,9 +39,14 @@ omni start
|
|
|
39
39
|
routes by *pace*: 5% remaining is fine minutes before a reset and urgent with
|
|
40
40
|
six days to run.
|
|
41
41
|
- **Issues its own keys.** Hand out gateway keys with per-key model allowlists
|
|
42
|
-
|
|
42
|
+
instead of sharing provider credentials, and bound each one by requests,
|
|
43
|
+
tokens, dollars, or requests in flight — per minute, per five hours, and per
|
|
44
|
+
week.
|
|
43
45
|
- **Reports usage.** Requests, tokens, and cost by provider, model, key, and
|
|
44
46
|
day — metadata only.
|
|
47
|
+
- **Backs itself up.** Snapshot the database from the console or the CLI, see
|
|
48
|
+
what it occupies, reclaim what deletion left behind, and restore a snapshot
|
|
49
|
+
without stopping the gateway.
|
|
45
50
|
- **Ships an admin console and a CLI.** Both cover the same ground; use
|
|
46
51
|
whichever suits the machine you are on.
|
|
47
52
|
|
|
@@ -65,17 +70,21 @@ graph TD
|
|
|
65
70
|
store["@omni/store<br/><i>SQLite + field encryption</i>"]
|
|
66
71
|
providers["@omni/providers<br/><i>adapters, wire codecs,<br/>catalog, HTTP client</i>"]
|
|
67
72
|
rtk["@omni/rtk<br/><i>tool-result filters</i>"]
|
|
73
|
+
ratelimit["@omni/ratelimit<br/><i>key limit arithmetic;<br/>no clock, no state</i>"]
|
|
68
74
|
ir["@omni/ir<br/><i>domain model — depends on nothing</i>"]
|
|
69
75
|
|
|
70
76
|
gateway --> control
|
|
71
77
|
gateway --> rtk
|
|
78
|
+
gateway --> ratelimit
|
|
72
79
|
cli --> control
|
|
73
80
|
dashboard -. "types + catalog only" .-> providers
|
|
74
81
|
dashboard -. "types only" .-> store
|
|
75
82
|
control --> router
|
|
83
|
+
control --> ratelimit
|
|
76
84
|
router --> store
|
|
77
85
|
router --> providers
|
|
78
86
|
store --> rtk
|
|
87
|
+
store --> ratelimit
|
|
79
88
|
providers --> ir
|
|
80
89
|
rtk --> ir
|
|
81
90
|
store --> ir
|
|
@@ -86,6 +95,14 @@ the work: `@omni/ir` is side-effect free and imports nothing, and `@omni/router`
|
|
|
86
95
|
is a pure function — no network, no database, no timers. Everything that has to
|
|
87
96
|
touch the world is pushed up into the gateway process.
|
|
88
97
|
|
|
98
|
+
`@omni/ratelimit` is the same shape applied to key limits: it decides whether a
|
|
99
|
+
request is over a ceiling, and it holds no clock and no counters — `now` is a
|
|
100
|
+
parameter and the counts are handed to it, so it never learns whether a number
|
|
101
|
+
came from memory or from SQLite. It imports nothing but its schema validator, not
|
|
102
|
+
even `@omni/ir`. The rings and the in-flight gauge live in the gateway, because
|
|
103
|
+
state is not the package's job, and that split is what lets sliding-window
|
|
104
|
+
arithmetic be tested without a gateway, a store, or a clock.
|
|
105
|
+
|
|
89
106
|
The dashboard's edges are dotted because they are type-level only.
|
|
90
107
|
`@omni/providers/catalog` is deliberately kept import-free so model lists can be
|
|
91
108
|
bundled into the browser without dragging in the HTTP client.
|
|
@@ -183,6 +200,47 @@ Mint a key for your client. **It is printed once and stored only as a hash:**
|
|
|
183
200
|
omni keys create --label laptop
|
|
184
201
|
```
|
|
185
202
|
|
|
203
|
+
Bound what the key can do with `--limit <dimension>:<window>=<value>`, repeated
|
|
204
|
+
once per pair. An unset pair is unlimited:
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
omni keys create --label ci --limit requests:1m=60
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Every window is *sliding*, so a key cannot spend two windows' allowance either
|
|
211
|
+
side of a clock edge. `requests` and `tokens` take `1m`, `5h`, and `1w`; `spend`
|
|
212
|
+
takes `5h` and `1w`; `concurrency` is not a window at all but a ceiling on
|
|
213
|
+
requests in flight at once.
|
|
214
|
+
|
|
215
|
+
`tokens` and `spend` are debited once a response completes, because an exact
|
|
216
|
+
count exists only then — a key at its ceiling is refused on its *next* request.
|
|
217
|
+
The `5h` and `1w` counts derive from `request_logs`, so a `1w` limit on an
|
|
218
|
+
installation that prunes logs after three days really enforces three days. They
|
|
219
|
+
are cached for thirty seconds and so can read slightly high, never low: refused
|
|
220
|
+
early rather than let past a ceiling you set. If the database cannot answer they
|
|
221
|
+
stop enforcing and the gateway logs it, while `1m` and `concurrency` are held in
|
|
222
|
+
memory and go on enforcing exactly.
|
|
223
|
+
|
|
224
|
+
> **Breaking:** `--rate-limit N` is removed, not aliased. Use
|
|
225
|
+
> `--limit requests:1m=N`. A script still passing the old flag stops with an
|
|
226
|
+
> unknown-flag error rather than quietly taking a deprecated path.
|
|
227
|
+
|
|
228
|
+
Limits are editable after the key exists, unlike `--no-bodies` below. `omni keys
|
|
229
|
+
list` prints a compact summary; the full matrix and what has gone against it
|
|
230
|
+
need one key's id:
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
omni keys limits <id>
|
|
234
|
+
omni keys limits <id> --set tokens:1w=50000000
|
|
235
|
+
omni keys limits <id> --unset spend:5h
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
`--unset` names a pair that is actually set, so a typo fails rather than
|
|
239
|
+
reporting a change it did not make. The usage shown counts completed requests
|
|
240
|
+
only, so it reads at or below what the gateway is enforcing, and `concurrency`
|
|
241
|
+
shows no figure — that gauge lives in the process, not the database. The
|
|
242
|
+
console's Keys screen shows and edits the same matrix.
|
|
243
|
+
|
|
186
244
|
Now use it:
|
|
187
245
|
|
|
188
246
|
```bash
|
|
@@ -223,6 +281,36 @@ once.
|
|
|
223
281
|
Most tools that accept a custom base URL work unchanged: set it to
|
|
224
282
|
`http://127.0.0.1:9000` and use a gateway key where the provider key goes.
|
|
225
283
|
|
|
284
|
+
### Rate-limit headers
|
|
285
|
+
|
|
286
|
+
Every response carries the limit headers of the surface you asked on, so an SDK
|
|
287
|
+
backs off using the code it already ships — the Anthropic dialect on
|
|
288
|
+
`/v1/messages`, the OpenAI one on `/v1/chat/completions`:
|
|
289
|
+
|
|
290
|
+
```http
|
|
291
|
+
anthropic-ratelimit-requests-limit: 2000 x-ratelimit-limit-requests: 2000
|
|
292
|
+
anthropic-ratelimit-requests-remaining: 1841 x-ratelimit-remaining-requests: 1841
|
|
293
|
+
anthropic-ratelimit-requests-reset: 2026-08-19T14:32:07Z x-ratelimit-reset-requests: 4h51m22s
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
`requests-remaining` counts the request you are being answered, as both vendors
|
|
297
|
+
define it. `tokens-remaining` does not, and cannot: the response is still being
|
|
298
|
+
written when the header goes out, so its token cost is not yet known and
|
|
299
|
+
subtracting anything would be an invented number rather than a measured one.
|
|
300
|
+
|
|
301
|
+
Where a key has several windows on one dimension, the headers report the one
|
|
302
|
+
**nearest exhaustion** — a key comfortable per-minute but one request from its
|
|
303
|
+
weekly ceiling shows you the weekly figures, not the reassuring ones.
|
|
304
|
+
|
|
305
|
+
`spend` and `concurrency` are rendered on neither dialect, because no vendor
|
|
306
|
+
defines a header for them and a number no client parses is noise in every
|
|
307
|
+
response.
|
|
308
|
+
|
|
309
|
+
A refusal is `429` with `Retry-After` in seconds, alongside the usual error body.
|
|
310
|
+
The wait is computed from the oldest request still inside the window that
|
|
311
|
+
refused you, so a weekly ceiling tells you when a slot actually frees rather than
|
|
312
|
+
parking you for seven days.
|
|
313
|
+
|
|
226
314
|
### Tools and routing
|
|
227
315
|
|
|
228
316
|
Two kinds of tool behave differently, and the difference decides where a
|
|
@@ -260,6 +348,7 @@ Use `--db <path>` to point one command somewhere else.
|
|
|
260
348
|
| `omni start` / `stop` / `restart` | run the gateway; `--foreground` attaches it to your terminal |
|
|
261
349
|
| `omni doctor` | which installation it resolved, and whether it can act on it |
|
|
262
350
|
| `omni logs` | recent requests as the gateway recorded them |
|
|
351
|
+
| `omni bodies <request-id>` | captured bodies for one request; withheld unless you pass `--full` |
|
|
263
352
|
| `omni console` | the gateway process's own output: boot, refreshes, quota, errors |
|
|
264
353
|
| `omni usage` | spend and tokens, by provider, model, key, or day |
|
|
265
354
|
| `omni quota` | provider quota per window: use, burn rate, and when it runs out |
|
|
@@ -267,9 +356,14 @@ Use `--db <path>` to point one command somewhere else.
|
|
|
267
356
|
| `omni credentials …` | list, show, enable, disable, retier, refresh, remove |
|
|
268
357
|
| `omni models …` | list, show, put, remove, `dry-run`, `catalog` |
|
|
269
358
|
| `omni keys …` | list, create, revoke |
|
|
270
|
-
| `omni
|
|
359
|
+
| `omni plugin …` | list, verify, install, remove; see [Plugins](#plugins) |
|
|
360
|
+
| `omni settings get` / `set` | routing weights, retention, deadlines, and the runtime switches |
|
|
271
361
|
| `omni admin set-password` | change the console password |
|
|
272
362
|
| `omni db migrate` | create or upgrade the database |
|
|
363
|
+
| `omni db stats` | size on disk, free pages, schema version, and what snapshots are held |
|
|
364
|
+
| `omni db backup` / `snapshots` | take a snapshot, and list the ones retention has kept |
|
|
365
|
+
| `omni db restore <id>` | put a snapshot back; asks first, and refuses while the gateway is running |
|
|
366
|
+
| `omni db vacuum` | rewrite the database, reclaiming the pages deletion left free |
|
|
273
367
|
|
|
274
368
|
Two worth knowing:
|
|
275
369
|
|
|
@@ -294,6 +388,29 @@ start` supervises the process itself with a pidfile under
|
|
|
294
388
|
`~/.local/state/omnigateway`. Either way, `omni start` returns only once
|
|
295
389
|
`/health` actually answers.
|
|
296
390
|
|
|
391
|
+
### Restarting and stopping from the console
|
|
392
|
+
|
|
393
|
+
The console's Database screen can restart and shut down the gateway. A restart
|
|
394
|
+
only restarts if something would start the process again, so what the control
|
|
395
|
+
does — and whether it is offered at all — depends on how this installation runs:
|
|
396
|
+
|
|
397
|
+
- **Under systemd**, it works. The gateway asks the manager rather than
|
|
398
|
+
signalling itself — `systemctl [--user] --no-block restart
|
|
399
|
+
omnigateway.service` — because the unit `omni service install` writes sets
|
|
400
|
+
`Restart=on-failure`, and a handled `SIGTERM` exits cleanly, which systemd
|
|
401
|
+
reads as success. A gateway that killed itself would stop and stay stopped.
|
|
402
|
+
- **In a container**, it depends on the container's restart policy, and that
|
|
403
|
+
policy cannot be read from inside the container. The console says so instead
|
|
404
|
+
of promising a restart it has no way to verify. Run with
|
|
405
|
+
`--restart unless-stopped` if you want the control to mean what it says.
|
|
406
|
+
- **With no supervisor** — `omni start` with no unit installed — the control is
|
|
407
|
+
disabled and shows the reason. Nothing is watching the process, so an exit is
|
|
408
|
+
simply the end of it. Use `omni restart` from a terminal on the machine.
|
|
409
|
+
|
|
410
|
+
Shutdown is offered in every shape, because stopping is the point of it. In a
|
|
411
|
+
container it is a one-way door: stopping the only process takes the console that
|
|
412
|
+
would have restarted it, so bringing the gateway back needs access to the host.
|
|
413
|
+
|
|
297
414
|
## Configuration
|
|
298
415
|
|
|
299
416
|
Configuration is environment variables, read from the installation's `.env`:
|
|
@@ -308,6 +425,7 @@ Configuration is environment variables, read from the installation's `.env`:
|
|
|
308
425
|
| `OMNI_STATIC_DIR` | No | the console shipped with the server | Serve a different console build |
|
|
309
426
|
| `OMNI_LOG_LEVEL` | No | `info` | Stdout threshold: `debug`, `info`, `warn`, or `error` |
|
|
310
427
|
| `OMNI_LOG_FILE` | No | the systemd journal, when there is one | Where stdout was already redirected, so the Console screen can read it back. Names a file; does not create one |
|
|
428
|
+
| `OMNI_BODY_LOGGING_ALLOWED` | No | unset | Permits request/response body capture on this installation. Read at boot. Capture also needs the runtime setting; see [Recording bodies](#recording-bodies) |
|
|
311
429
|
|
|
312
430
|
Gateway events are written to stdout as one greppable line each: process lifecycle, OAuth
|
|
313
431
|
refreshes, quota probes, failover, and errors.
|
|
@@ -330,6 +448,193 @@ environment. Edit it with `omni settings set` or in the console.
|
|
|
330
448
|
`.env.example` in the repository documents the optional provider
|
|
331
449
|
client-identity overrides.
|
|
332
450
|
|
|
451
|
+
## Recording bodies
|
|
452
|
+
|
|
453
|
+
By default the gateway records no prompts and no responses. If you need them for
|
|
454
|
+
an incident — to see what a client actually sent, or what a provider actually
|
|
455
|
+
returned — capture is opt-in and takes **two independent keys, both required**:
|
|
456
|
+
|
|
457
|
+
1. `OMNI_BODY_LOGGING_ALLOWED=1` in the installation's `.env`, read at boot.
|
|
458
|
+
2. The **Capture request and response bodies** setting, in the console's
|
|
459
|
+
Settings screen or `omni settings set bodyLoggingEnabled true`. Off by
|
|
460
|
+
default. Raw SSE frames are the separate, far larger
|
|
461
|
+
`bodyLoggingCaptureStreamChunks`.
|
|
462
|
+
|
|
463
|
+
Two keys, because an admin session on its own must not be able to start
|
|
464
|
+
recording your users' prompts. With the environment variable unset the setting
|
|
465
|
+
does nothing at all, and the console says so rather than letting you flip a
|
|
466
|
+
switch that silently no-ops. With the environment variable set you can turn
|
|
467
|
+
capture on and off mid-incident without restarting.
|
|
468
|
+
|
|
469
|
+
Turning capture off stops new capture. It does not delete what was already
|
|
470
|
+
written.
|
|
471
|
+
|
|
472
|
+
**Per-key opt-out.** A gateway key can be created with *Never record this key's
|
|
473
|
+
bodies* — `omni keys create --no-bodies` — and it is then never captured whatever
|
|
474
|
+
the setting says. Use it for a client whose payloads must not be retained. The
|
|
475
|
+
choice is made when the key is issued and cannot be reversed afterwards; reissue
|
|
476
|
+
the key instead. `omni keys list` and the console's Keys screen both show which
|
|
477
|
+
keys are exempt, so an audit does not have to go through the database.
|
|
478
|
+
|
|
479
|
+
**What is captured.** What arrived at `/v1/*` and what was returned, plus the
|
|
480
|
+
request and response of every provider attempt, in dispatch order. The client
|
|
481
|
+
request is the conversation *before* RTK compression and each attempt request is
|
|
482
|
+
the one *after* it, so an artifact is the only place you can read what a filter
|
|
483
|
+
actually removed. The console labels which side is which, and so does the CLI.
|
|
484
|
+
|
|
485
|
+
**Reading them.** Expand a row on the console's Logs screen, or from a terminal:
|
|
486
|
+
|
|
487
|
+
```bash
|
|
488
|
+
omni bodies req_550e8400-… # the frame: state, size, one line per attempt
|
|
489
|
+
omni bodies req_550e8400-… --full # the payloads themselves
|
|
490
|
+
omni bodies req_550e8400-… --json # the artifact, for a script
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
**The bare command withholds the bodies and prints only the frame.** Every other
|
|
494
|
+
CLI read prints everything it has; this one prints conversations, and whoever
|
|
495
|
+
runs it during an incident is usually sharing that screen. Asking costs one
|
|
496
|
+
flag; printing by default costs a prompt corpus in someone's scrollback,
|
|
497
|
+
silently. The frame still gives you the state, capture time, size on disk, any
|
|
498
|
+
truncation, and each attempt's provider and byte counts — labelled `pre-RTK` for
|
|
499
|
+
the client request and `post-RTK` for attempts, because they are not the same
|
|
500
|
+
payload.
|
|
501
|
+
|
|
502
|
+
A request with no artifact is an answer rather than an error, and the three
|
|
503
|
+
answers are different: `not captured` means capture was not running, `captured,
|
|
504
|
+
then lost` means retention or the row cap has been through, and `captured, but
|
|
505
|
+
unreadable` means the file is there but will not decrypt — usually a changed
|
|
506
|
+
`OMNI_ENCRYPTION_KEY`.
|
|
507
|
+
|
|
508
|
+
There is no CLI command to delete a captured body. Retention, the row cap, and
|
|
509
|
+
the orphan sweep are the gateway's; a second path that erases forensic evidence
|
|
510
|
+
on request is a way to lose an incident record.
|
|
511
|
+
|
|
512
|
+
**What is never captured.** Headers, at any layer — every provider authenticates
|
|
513
|
+
through headers, so that is where the tokens are. That one is a guarantee: the
|
|
514
|
+
capture layer is never handed a header list, so no provider, present or future,
|
|
515
|
+
can opt its own in.
|
|
516
|
+
|
|
517
|
+
**Masking is best-effort, and a body corpus is sensitive even after it.** Bodies
|
|
518
|
+
are masked before they are written, replacing bearer tokens, `sk-`/`ak-`/`pk-`
|
|
519
|
+
prefixed keys, the well-known vendor prefixes (`ghp_` and the rest of GitHub's,
|
|
520
|
+
`github_pat_`, `AIza`, `GOCSPX-`, `xai-`), and any long opaque token with elided
|
|
521
|
+
forms. Two things follow, and both matter before you turn capture on:
|
|
522
|
+
|
|
523
|
+
- It costs fidelity. The length rule has no idea what it is looking at, so it
|
|
524
|
+
also elides base64 image data, content hashes, and minified source. That is
|
|
525
|
+
deliberate: a corpus that leaks a live credential is the worse failure.
|
|
526
|
+
- It does not catch everything. The length rule is tuned to base64url, so a
|
|
527
|
+
standard-base64 secret or an AWS secret access key can slip through it on a
|
|
528
|
+
`+` or a `/`, and a credential shorter than forty-one characters or exactly
|
|
529
|
+
forty characters long — an Azure OpenAI key, for instance — is out of its reach
|
|
530
|
+
entirely. The prefix rules exist precisely because the length rule cannot be
|
|
531
|
+
the whole answer, and between them they are a reduction in exposure, not a
|
|
532
|
+
guarantee of none.
|
|
533
|
+
|
|
534
|
+
Treat the artifact tree as you would treat the prompts themselves: it is
|
|
535
|
+
encrypted at rest, it belongs on a volume you control, and it is not something to
|
|
536
|
+
copy into a ticket.
|
|
537
|
+
|
|
538
|
+
Nothing changes about stdout. Prompts and responses never reach the log, the
|
|
539
|
+
journal, or the Console screen; capture is a separate encrypted store.
|
|
540
|
+
|
|
541
|
+
**Where it goes.** `request_bodies/YYYY/MM/DD/<requestId>.json.enc` beside the
|
|
542
|
+
database file, encrypted with AES-256-GCM under `OMNI_ENCRYPTION_KEY`, the same
|
|
543
|
+
key as your provider credentials. Copies taken without the key yield nothing.
|
|
544
|
+
Changing the key invalidates every artifact already written.
|
|
545
|
+
|
|
546
|
+
**Bounds.** Two limits, because either alone fails:
|
|
547
|
+
|
|
548
|
+
- Bodies expire on the same **log retention** window as request rows, swept
|
|
549
|
+
hourly, file and row deleted together.
|
|
550
|
+
- A hard cap of **100,000 body rows**, oldest pruned first. The window is what
|
|
551
|
+
you reason about; the row cap is what actually bounds disk, because a week's
|
|
552
|
+
window over sustained traffic bounds nothing.
|
|
553
|
+
|
|
554
|
+
Individual payloads are bounded structurally rather than by byte offset — strings
|
|
555
|
+
past 64 KB, arrays to their last 24 items, nesting past 6 levels, objects to 80
|
|
556
|
+
keys — so a stored artifact is always valid JSON. An artifact still over 512 KB
|
|
557
|
+
after that has its bodies replaced by a marker recording why.
|
|
558
|
+
|
|
559
|
+
**Sizing.** 512 KB is a *plaintext* cap and encryption emits hex, so one artifact
|
|
560
|
+
can reach ~1 MB on disk: with the 100,000-row cap the corpus worst case is about
|
|
561
|
+
**100 GB**, not 50. Most artifacts are a few kilobytes, but that is the number to
|
|
562
|
+
size a volume against. The same cap applies per body held in memory while a
|
|
563
|
+
request is in flight, one per side per attempt — so ~512 KB × (attempts + 1) per
|
|
564
|
+
captured request. Multiply by your concurrency before enabling this on a small
|
|
565
|
+
box.
|
|
566
|
+
|
|
567
|
+
## Snapshots and restore
|
|
568
|
+
|
|
569
|
+
The console's Database screen reports what this installation occupies — the
|
|
570
|
+
database file, its write-ahead log, the captured-body tree, and the free pages a
|
|
571
|
+
compaction would give back — and takes snapshots. `omni db stats` prints the same
|
|
572
|
+
figures.
|
|
573
|
+
|
|
574
|
+
**What a snapshot is.** One self-contained SQLite file, written into a
|
|
575
|
+
`snapshots/` directory beside the database. The write-ahead log is folded in, so
|
|
576
|
+
there is nothing else to copy alongside it, and taking one is safe while the
|
|
577
|
+
gateway is running: it reads through SQLite rather than copying bytes off disk.
|
|
578
|
+
|
|
579
|
+
**What it is not.** The sibling `request_bodies/` tree is excluded, always. A
|
|
580
|
+
snapshot is never a prompt corpus, and its size tracks your configuration and
|
|
581
|
+
usage history rather than your traffic. The cost is that a restore leaves the
|
|
582
|
+
captured-body tree out of step with the table: files the restored database has no
|
|
583
|
+
row for are collected by the hourly sweep, and a row whose file is gone reads back
|
|
584
|
+
as `captured, then lost`.
|
|
585
|
+
|
|
586
|
+
**A snapshot does carry secrets.** Your provider credentials are in it, encrypted,
|
|
587
|
+
along with your gateway key hashes. They are inert only because
|
|
588
|
+
`OMNI_ENCRYPTION_KEY` is not in the file — it lives in the installation's `.env`.
|
|
589
|
+
So treat a downloaded snapshot exactly as you treat the database itself: anyone
|
|
590
|
+
holding both the file and the key holds your provider accounts. Downloads are
|
|
591
|
+
never cached, and each one is recorded in the gateway's log.
|
|
592
|
+
|
|
593
|
+
**Retention** bounds the directory: at most `keepLatest` snapshots are kept, and
|
|
594
|
+
nothing older than `maxAgeDays` — 5 and 30 by default. Both bounds have to pass,
|
|
595
|
+
so an old snapshot goes even while the count is under the limit, and the newest is
|
|
596
|
+
always kept whatever the numbers say. Pruning runs when a snapshot is taken rather
|
|
597
|
+
than on a timer, so a quiet installation keeps what it already has. **Edit the
|
|
598
|
+
policy on the Database screen**, not on Settings: it is deliberately not part of
|
|
599
|
+
the settings form, so a settings save from a client that has never heard of
|
|
600
|
+
retention leaves your policy alone instead of resetting it.
|
|
601
|
+
|
|
602
|
+
The copy taken automatically on the way into a restore is exempt from retention.
|
|
603
|
+
It is the undo.
|
|
604
|
+
|
|
605
|
+
**Restoring from the console** happens inside the running gateway. Client traffic
|
|
606
|
+
on `/v1/*` is refused with a retryable 503 while the file is replaced; `/api/*`
|
|
607
|
+
and `/health` keep answering, so you can watch the restore and hear how it ended.
|
|
608
|
+
Restoring a database that carries a different console password signs every session
|
|
609
|
+
out; restoring this installation's own snapshot does not. The screen also uploads
|
|
610
|
+
a database file from elsewhere, up to 2 GiB, which is how you move an installation
|
|
611
|
+
to another machine — bring `OMNI_ENCRYPTION_KEY` with it, or the credentials in it
|
|
612
|
+
are unreadable. Either way the file is integrity-checked before anything is
|
|
613
|
+
touched, and a copy of what was there is taken first.
|
|
614
|
+
|
|
615
|
+
**A restore ends by rebuilding the hourly usage rollup, and that step blocks.**
|
|
616
|
+
The rollup is what rate limits count their 5h and 1w windows from, and no file an
|
|
617
|
+
operator hands over says whether its counters agree with its rows — so it is
|
|
618
|
+
recomputed rather than trusted. `bun:sqlite` is synchronous, so the grouped scan
|
|
619
|
+
holds the event loop: roughly 0.4 s per 500k request-log rows, 1.6 s per 2M, and
|
|
620
|
+
about 6.5 s at 8M. `/api/*` and `/health` do not answer during it. It is the last
|
|
621
|
+
thing a restore does, after the swap has already succeeded, and a failure is
|
|
622
|
+
logged rather than raised — the database is live either way, and `omni doctor`
|
|
623
|
+
reports a rollup that disagrees with its rows.
|
|
624
|
+
|
|
625
|
+
**`omni db restore <id>` refuses while a gateway is running** against that
|
|
626
|
+
installation, and there is no override flag. A second process can open its own
|
|
627
|
+
handle but cannot quiesce the gateway's, and moving the file out from under a live
|
|
628
|
+
SQLite connection corrupts the database you were trying to rescue. Run `omni stop`
|
|
629
|
+
first, or restore from the console, which swaps the file behind its own quiesce
|
|
630
|
+
latch.
|
|
631
|
+
|
|
632
|
+
**Compaction.** `omni db vacuum`, or the console's equivalent, rewrites the
|
|
633
|
+
database and reclaims the pages deletion left free. It holds SQLite's write lock
|
|
634
|
+
for the rewrite, so a busy gateway stalls on its writes until it finishes — but
|
|
635
|
+
nothing is lost by running it live, and it reports what it actually gave back to
|
|
636
|
+
the filesystem.
|
|
637
|
+
|
|
333
638
|
## Docker
|
|
334
639
|
|
|
335
640
|
```bash
|
|
@@ -346,45 +651,237 @@ The container listens on `0.0.0.0:9000` and keeps its database at
|
|
|
346
651
|
serves the APIs and returns 404 for the console. Use the CLI or the control API
|
|
347
652
|
against it, or install the npm package if you want the console.
|
|
348
653
|
|
|
654
|
+
Give the container a restart policy — `--restart unless-stopped` — if you want a
|
|
655
|
+
restart request to bring it back. A container cannot read its own policy, so
|
|
656
|
+
without one an exit is simply the end of the installation until you start it
|
|
657
|
+
again from the host.
|
|
658
|
+
|
|
349
659
|
## Scope and limits
|
|
350
660
|
|
|
351
661
|
Worth knowing before you deploy it:
|
|
352
662
|
|
|
353
663
|
- **One machine, one operator.** No multi-tenancy, no clustering, no shared
|
|
354
|
-
state.
|
|
664
|
+
state. The `1m` window and the `concurrency` gauge are counted in the gateway
|
|
665
|
+
process and reset when it restarts; `5h` and `1w` are counted from the database
|
|
666
|
+
and survive one. Two gateways over one database would not see each other's
|
|
667
|
+
short-window counts.
|
|
355
668
|
- **Two grains of usage history.** Detailed request logs are pruned after 30
|
|
356
669
|
days by default; a daily rollup is kept for 400 days. A day is your host's
|
|
357
670
|
local midnight, fixed when the row is written.
|
|
671
|
+
- **Body capture is forensics, not an archive.** It is off unless you turn it on
|
|
672
|
+
with both keys, it expires on the request-log window, and it is capped at
|
|
673
|
+
100,000 rows. It is not a searchable prompt history and there is no CLI for it.
|
|
674
|
+
- **Snapshots are manual, and local.** Nothing takes one on a schedule and there
|
|
675
|
+
is no off-host target; retention bounds what you have taken, and a restore
|
|
676
|
+
always takes one first. Copy them somewhere else yourself if the disk failing
|
|
677
|
+
is what you are guarding against.
|
|
358
678
|
- **Quota readings come from the providers**, and their usage endpoints are
|
|
359
679
|
undocumented. An account with nothing reported is treated as unknown, never
|
|
360
680
|
as unlimited.
|
|
361
681
|
- **The gateway does not know which model accepts which request shape.** An
|
|
362
682
|
unsupported combination surfaces as the provider's own 400 rather than being
|
|
363
683
|
caught earlier.
|
|
364
|
-
- Not in scope for version 1: semantic caching, billing,
|
|
365
|
-
horizontal scaling.
|
|
684
|
+
- Not in scope for version 1: semantic caching, billing, horizontal scaling.
|
|
366
685
|
|
|
367
686
|
## Security
|
|
368
687
|
|
|
369
688
|
- Treat `OMNI_ENCRYPTION_KEY`, gateway keys, and the SQLite file as secrets.
|
|
370
689
|
Anyone with the file *and* the key has your provider credentials.
|
|
371
690
|
- Prompts and responses are never logged. Request logs hold metadata and token
|
|
372
|
-
counts only
|
|
691
|
+
counts only, and no body ever reaches stdout, the journal, or the Console
|
|
692
|
+
screen. Bodies are stored only if you opt in to
|
|
693
|
+
[body capture](#recording-bodies), which needs both an environment variable
|
|
694
|
+
and a setting, encrypts what it writes, and can be refused per key.
|
|
373
695
|
- Gateway keys are stored as hashes. A lost key is reissued, not recovered.
|
|
696
|
+
- A [snapshot](#snapshots-and-restore) carries whatever the database does —
|
|
697
|
+
encrypted provider credentials, gateway key hashes — and is inert only because
|
|
698
|
+
`OMNI_ENCRYPTION_KEY` is not in it. Captured bodies are excluded, so a snapshot
|
|
699
|
+
is never a prompt corpus.
|
|
374
700
|
- Secrets are never accepted on the command line — `omni` prompts for them or
|
|
375
701
|
reads stdin — so they stay out of your shell history and the process table.
|
|
376
702
|
- Behind a reverse proxy, set `OMNI_BASE_URL` to the public HTTPS origin so
|
|
377
703
|
OAuth callbacks match what the providers have registered.
|
|
378
704
|
- The gateway talks to your providers and to nobody else. No telemetry, no CDN
|
|
379
|
-
fonts, no third-party origins.
|
|
705
|
+
fonts, no third-party origins. A plugin may declare outbound origins of its
|
|
706
|
+
own, and `omni plugin verify <id>` shows exactly which ones it asked for — as
|
|
707
|
+
does its manifest, which is a plain file you can read before installing.
|
|
708
|
+
- **Plugins run inside the gateway process, with its privileges.** The
|
|
709
|
+
capability context they are handed is a guardrail against mistakes, not a
|
|
710
|
+
sandbox against malice: a plugin shares the process holding your encryption
|
|
711
|
+
key and decrypted provider credentials, and can reach past the context if it
|
|
712
|
+
wants to. Install plugins you wrote or audited, and read a plugin's manifest —
|
|
713
|
+
it lists every capability and outbound origin it asked for.
|
|
714
|
+
|
|
715
|
+
## Plugins
|
|
716
|
+
|
|
717
|
+
A plugin adds routes, storage, and a screen in the console to one installation,
|
|
718
|
+
without being part of OmniGateway. Most installations run none.
|
|
719
|
+
|
|
720
|
+
```bash
|
|
721
|
+
omni plugin install ./some-plugin # a directory, or a .tgz
|
|
722
|
+
omni plugin install https://…/x.tgz # a tarball over https, never http
|
|
723
|
+
omni plugin install some-plugin@1.2.3 # a package name, through the npm registry
|
|
724
|
+
omni plugin verify some-plugin # every check the next boot will run
|
|
725
|
+
omni plugin list # what is installed, and whether it would load
|
|
726
|
+
omni restart # plugins load at boot, so this is required
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
**Nothing in the package is executed by any of these.** There is no dependency
|
|
730
|
+
resolution, no `node_modules`, and no lifecycle script — the installer fetches,
|
|
731
|
+
checks, and unpacks, and the plugin's own code is first imported at the next boot.
|
|
732
|
+
|
|
733
|
+
A spec is resolved filesystem-first: directory, then local tarball, then URL,
|
|
734
|
+
then registry. That order is the safe one. The reverse would let a published
|
|
735
|
+
package shadow the directory you are standing in and turn `omni plugin install
|
|
736
|
+
some-plugin` into a download nobody asked for.
|
|
737
|
+
|
|
738
|
+
Installing by name refuses more than it accepts, and each refusal happens before
|
|
739
|
+
any bytes are fetched: the tarball must be served from the registry's own host,
|
|
740
|
+
the registry must advertise an integrity hash or a shasum, and only an exact
|
|
741
|
+
version or the registry's `latest` resolves — no ranges, no other dist-tags. Use
|
|
742
|
+
`--registry` (or `OMNI_PLUGIN_REGISTRY`) for a private registry; it must be
|
|
743
|
+
`https://`.
|
|
744
|
+
|
|
745
|
+
A URL you type is different, and the difference is the point: nothing downstream
|
|
746
|
+
has a digest to check it against, so TLS to the host you named is the only
|
|
747
|
+
assurance there is. That is why `http://` is refused rather than upgraded.
|
|
748
|
+
|
|
749
|
+
`omni plugin list` prints what this installation has — id, name, version, the
|
|
750
|
+
plugin API and console SDK it was built against, the capabilities it declared,
|
|
751
|
+
and whether the gateway would load it:
|
|
752
|
+
|
|
753
|
+
```
|
|
754
|
+
ID NAME VERSION API SDK CAPABILITIES STATE
|
|
755
|
+
pokemon Pokémon Companion 1.0.0 1 ^1.0.0 storage,files,net:outbound,… ok
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
A plugin that would *not* load is listed with the reason rather than hidden,
|
|
759
|
+
because a plugin missing from the console is exactly what you are trying to
|
|
760
|
+
explain. For one plugin's full detail — its entry points and the outbound
|
|
761
|
+
origins it declared — use `omni plugin verify <id>`.
|
|
762
|
+
|
|
763
|
+
### Available plugins
|
|
764
|
+
|
|
765
|
+
There is no curated directory to browse, and there is no plan for one. A plugin
|
|
766
|
+
is a directory, a tarball, a URL or a package name you point `omni plugin
|
|
767
|
+
install` at, and you are expected to know where it came from — see the
|
|
768
|
+
[security note](#security) for why that is the model rather than an omission.
|
|
769
|
+
|
|
770
|
+
Resolving a name through npm makes distribution easier; it does not make an
|
|
771
|
+
unknown plugin safer. Integrity checking proves you received the bytes the
|
|
772
|
+
registry advertised, and nothing about who wrote them or what they do once the
|
|
773
|
+
gateway imports them.
|
|
774
|
+
|
|
775
|
+
One ships in this repository:
|
|
776
|
+
|
|
777
|
+
| Plugin | What it does |
|
|
778
|
+
| --- | --- |
|
|
779
|
+
| [`pokemon`](plugins/pokemon) | A Pokémon companion. Each gateway key raises one that hatches, evolves, and graduates into a Pokédex on the tokens that key spends, with a shop that spends a wallet of those same tokens. |
|
|
780
|
+
|
|
781
|
+
It is not installed by default, is not in the npm package, and is not in the
|
|
782
|
+
Docker image. Build it from a checkout and install the result:
|
|
783
|
+
|
|
784
|
+
```bash
|
|
785
|
+
bun run build:plugins # writes plugins/pokemon/dist/pokemon
|
|
786
|
+
omni plugin install ./plugins/pokemon/dist/pokemon
|
|
787
|
+
omni restart
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
The path ends in `pokemon` because the installer takes the target directory name
|
|
791
|
+
from the source and refuses a manifest whose id disagrees with it — so a plugin
|
|
792
|
+
cannot be installed under a name that is not its own.
|
|
793
|
+
|
|
794
|
+
It needs outbound access to `pokeapi.co` and `raw.githubusercontent.com` for
|
|
795
|
+
species data and sprites, which its manifest declares and `omni plugin verify
|
|
796
|
+
pokemon` prints back. Those assets are Nintendo and Game Freak intellectual property, fetched at
|
|
797
|
+
runtime and never vendored into this repository or its published artifacts.
|
|
798
|
+
|
|
799
|
+
`verify` is the one to run before restarting a gateway that people are using: it
|
|
800
|
+
reaches the same verdict the next boot will, from the same code, without loading
|
|
801
|
+
the plugin.
|
|
802
|
+
|
|
803
|
+
### Installing on a machine with no checkout
|
|
804
|
+
|
|
805
|
+
A published plugin installs by name, and the host needs no checkout and no build
|
|
806
|
+
toolchain:
|
|
807
|
+
|
|
808
|
+
```bash
|
|
809
|
+
omni plugin install omnigateway-plugin-example
|
|
810
|
+
omni plugin verify example && omni restart
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
An `npm pack` tarball is rooted at `package/` rather than at the plugin's name,
|
|
814
|
+
and that is fine: the manifest's `id` names the installed directory when the
|
|
815
|
+
archive root does not.
|
|
816
|
+
|
|
817
|
+
For a plugin you build yourself and do not publish, ship the tarball and install
|
|
818
|
+
from the path:
|
|
819
|
+
|
|
820
|
+
```bash
|
|
821
|
+
# wherever you build — a workstation, CI
|
|
822
|
+
bun run build:plugins
|
|
823
|
+
tar -czf pokemon.tgz -C plugins/pokemon/dist pokemon
|
|
824
|
+
|
|
825
|
+
# on the host
|
|
826
|
+
scp pokemon.tgz gateway-host:/tmp/
|
|
827
|
+
ssh gateway-host 'omni plugin install /tmp/pokemon.tgz && omni plugin verify pokemon && omni restart'
|
|
828
|
+
```
|
|
829
|
+
|
|
830
|
+
Plaintext `http://` is refused outright and always will be: what arrives over
|
|
831
|
+
that fetch is code the gateway process will `import`, so anyone between you and
|
|
832
|
+
the host would be choosing what the gateway runs. Silently upgrading to `https://`
|
|
833
|
+
would be worse — it would install *something* from a URL you did not type.
|
|
834
|
+
|
|
835
|
+
**In Docker**, the image carries the gateway only, so a plugin arrives on a
|
|
836
|
+
volume rather than through the CLI. Mount it at `<root>/plugins/<id>` — the same
|
|
837
|
+
layout `install` writes — and restart the container:
|
|
838
|
+
|
|
839
|
+
```bash
|
|
840
|
+
docker run --rm -p 9000:9000 \
|
|
841
|
+
-e OMNI_ENCRYPTION_KEY="$OMNI_ENCRYPTION_KEY" \
|
|
842
|
+
-v omnigateway-data:/data \
|
|
843
|
+
-v "$PWD/pokemon:/data/plugins/pokemon" \
|
|
844
|
+
omnigateway
|
|
845
|
+
```
|
|
846
|
+
|
|
847
|
+
**Do not mount it `:ro`** if the plugin declares the `files` capability. A
|
|
848
|
+
plugin's cache lives at `<root>/plugins/<id>/data/`, inside its own directory, and
|
|
849
|
+
the capability creates that directory on every call — so a read-only mount fails
|
|
850
|
+
*reads* as well as writes, with an `EACCES` on `mkdir` rather than anything that
|
|
851
|
+
names the mount. Keeping a plugin's code immutable while its cache stays
|
|
852
|
+
writable is not expressible today; mount the directory read-write.
|
|
853
|
+
|
|
854
|
+
Removing one keeps its data:
|
|
855
|
+
|
|
856
|
+
```bash
|
|
857
|
+
omni plugin remove some-plugin # directory goes, database tables stay
|
|
858
|
+
omni plugin remove some-plugin --purge # tables too, after confirming
|
|
859
|
+
```
|
|
860
|
+
|
|
861
|
+
That default is deliberate. A plugin directory can be reinstalled from the
|
|
862
|
+
package it came from; whatever it accumulated in your database cannot be
|
|
863
|
+
reinstalled from anything.
|
|
864
|
+
|
|
865
|
+
Note what "directory goes" includes: a plugin's `data/` directory is removed
|
|
866
|
+
with it. That directory holds cached files a plugin can rebuild — it is excluded
|
|
867
|
+
from [snapshots](#snapshots-and-restore) for that reason, so it has no restore
|
|
868
|
+
path and is not meant to need one. Only the database tables are kept, and only
|
|
869
|
+
those are what `--purge` additionally drops. For the same reason, restoring a snapshot onto an
|
|
870
|
+
installation that no longer has a plugin leaves that plugin's tables in place —
|
|
871
|
+
`omni doctor` reports them, and nothing removes them for you.
|
|
872
|
+
|
|
873
|
+
Read the [security note](#security) on what a plugin can reach before installing
|
|
874
|
+
one you did not write. To write one, see
|
|
875
|
+
[docs/writing-a-plugin.md](docs/writing-a-plugin.md).
|
|
380
876
|
|
|
381
877
|
## Development
|
|
382
878
|
|
|
383
879
|
Contributing, or running from a checkout? See
|
|
384
880
|
[ARCHITECTURE.md](ARCHITECTURE.md) for how the system fits together,
|
|
385
881
|
[CLAUDE.md](CLAUDE.md) for the repository map, architectural boundaries, and
|
|
386
|
-
conventions,
|
|
387
|
-
|
|
882
|
+
conventions, [docs/adding-a-provider.md](docs/adding-a-provider.md) for the
|
|
883
|
+
provider checklist, and `docs/superpowers/specs/` for the design documents
|
|
884
|
+
behind each feature.
|
|
388
885
|
|
|
389
886
|
```bash
|
|
390
887
|
git clone https://github.com/harismawan/omnigateway.git
|