omnigateway 0.2.2 → 0.3.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.
Files changed (54) hide show
  1. package/README.md +334 -8
  2. package/bin/omni.js +1929 -147
  3. package/gateway.js +3129 -555
  4. package/package.json +1 -1
  5. package/public/assets/{Chip-DJwp7gBu.js → Chip-DbKk1ExR.js} +1 -1
  6. package/public/assets/Confirm-C5DYoI93.js +4 -0
  7. package/public/assets/CopyValue-CXjietiZ.js +27 -0
  8. package/public/assets/{Field-BDtp692L.js → Field-B7GOsJf8.js} +1 -1
  9. package/public/assets/Lamp-QRSv-SqM.js +25 -0
  10. package/public/assets/{Meter-BREBUFGX.js → Meter-Dngwbpjo.js} +1 -1
  11. package/public/assets/Modal-DfltaSCz.js +82 -0
  12. package/public/assets/Rack-D1zBw9SW.js +151 -0
  13. package/public/assets/{Readout-CEAoN5hu.js → Readout--CQnPjoO.js} +2 -2
  14. package/public/assets/{States-BEKLros0.js → States-CQFkw1UZ.js} +3 -3
  15. package/public/assets/{Table-BBypbVWj.js → Table-Ba58Wg4-.js} +1 -1
  16. package/public/assets/Toggle-DWrWc9-9.js +39 -0
  17. package/public/assets/TokenBreakdown-CyHJv0O5.js +31 -0
  18. package/public/assets/_app-CTDgfstQ.js +1 -0
  19. package/public/assets/_app.accounts-DP6T8z7I.js +64 -0
  20. package/public/assets/{_app.console-DKyGL9xx.js → _app.console-DYr7TCAl.js} +10 -10
  21. package/public/assets/_app.database-BzO1S_VR.js +39 -0
  22. package/public/assets/_app.index-CBt6b-lA.js +62 -0
  23. package/public/assets/_app.keys-B8HGsPPS.js +76 -0
  24. package/public/assets/_app.logs-DKsxa2Su.js +68 -0
  25. package/public/assets/{_app.models-CDqVrAeF.js → _app.models-CZD2UpM-.js} +28 -28
  26. package/public/assets/_app.settings-B2C1LQIF.js +46 -0
  27. package/public/assets/_app.usage-CDWUXtZy.js +83 -0
  28. package/public/assets/dist-IkJ0qg-4.js +1 -0
  29. package/public/assets/index-ClU3Xqui.js +186 -0
  30. package/public/assets/login-E3gh2_Cl.js +36 -0
  31. package/public/assets/plus-DkO4vG-T.js +1 -0
  32. package/public/assets/queries-BJHk_wso.js +144 -0
  33. package/public/assets/reasons-Cb8yF4Oy.js +1 -0
  34. package/public/assets/{shared-CIhZ6FCO.js → shared-Bp35cgFf.js} +7 -7
  35. package/public/assets/{trash-2-CLYNAAJA.js → trash-2-DTWWI3vA.js} +1 -1
  36. package/public/index.html +2 -2
  37. package/public/assets/Confirm-CVLz9F7a.js +0 -4
  38. package/public/assets/CopyValue-CNZbWS59.js +0 -27
  39. package/public/assets/Lamp-B65uWKbW.js +0 -25
  40. package/public/assets/Modal-DbydSAlp.js +0 -82
  41. package/public/assets/Rack-Bbo4JfEX.js +0 -151
  42. package/public/assets/Toggle-BUr71O4J.js +0 -39
  43. package/public/assets/TokenBreakdown-C1ziK9d1.js +0 -31
  44. package/public/assets/_app-ByQ1bPbe.js +0 -1
  45. package/public/assets/_app.accounts-BTug_Xfo.js +0 -64
  46. package/public/assets/_app.index-CSw9gmMf.js +0 -62
  47. package/public/assets/_app.keys-07Kbw4RS.js +0 -39
  48. package/public/assets/_app.logs-C9QiV39p.js +0 -32
  49. package/public/assets/_app.settings-DmUEeTaP.js +0 -38
  50. package/public/assets/_app.usage-B1WuMGRr.js +0 -83
  51. package/public/assets/dist-Bka7ErcH.js +0 -1
  52. package/public/assets/index-DJfb1i4P.js +0 -186
  53. package/public/assets/login-DKZYqRh0.js +0 -33
  54. package/public/assets/queries-D8LJNQpL.js +0 -144
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
- and rate limits instead of sharing provider credentials.
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,13 @@ 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 settings get` / `set` | routing weights, retention, deadlines |
359
+ | `omni settings get` / `set` | routing weights, retention, deadlines, and the runtime switches |
271
360
  | `omni admin set-password` | change the console password |
272
361
  | `omni db migrate` | create or upgrade the database |
362
+ | `omni db stats` | size on disk, free pages, schema version, and what snapshots are held |
363
+ | `omni db backup` / `snapshots` | take a snapshot, and list the ones retention has kept |
364
+ | `omni db restore <id>` | put a snapshot back; asks first, and refuses while the gateway is running |
365
+ | `omni db vacuum` | rewrite the database, reclaiming the pages deletion left free |
273
366
 
274
367
  Two worth knowing:
275
368
 
@@ -294,6 +387,29 @@ start` supervises the process itself with a pidfile under
294
387
  `~/.local/state/omnigateway`. Either way, `omni start` returns only once
295
388
  `/health` actually answers.
296
389
 
390
+ ### Restarting and stopping from the console
391
+
392
+ The console's Database screen can restart and shut down the gateway. A restart
393
+ only restarts if something would start the process again, so what the control
394
+ does — and whether it is offered at all — depends on how this installation runs:
395
+
396
+ - **Under systemd**, it works. The gateway asks the manager rather than
397
+ signalling itself — `systemctl [--user] --no-block restart
398
+ omnigateway.service` — because the unit `omni service install` writes sets
399
+ `Restart=on-failure`, and a handled `SIGTERM` exits cleanly, which systemd
400
+ reads as success. A gateway that killed itself would stop and stay stopped.
401
+ - **In a container**, it depends on the container's restart policy, and that
402
+ policy cannot be read from inside the container. The console says so instead
403
+ of promising a restart it has no way to verify. Run with
404
+ `--restart unless-stopped` if you want the control to mean what it says.
405
+ - **With no supervisor** — `omni start` with no unit installed — the control is
406
+ disabled and shows the reason. Nothing is watching the process, so an exit is
407
+ simply the end of it. Use `omni restart` from a terminal on the machine.
408
+
409
+ Shutdown is offered in every shape, because stopping is the point of it. In a
410
+ container it is a one-way door: stopping the only process takes the console that
411
+ would have restarted it, so bringing the gateway back needs access to the host.
412
+
297
413
  ## Configuration
298
414
 
299
415
  Configuration is environment variables, read from the installation's `.env`:
@@ -308,6 +424,7 @@ Configuration is environment variables, read from the installation's `.env`:
308
424
  | `OMNI_STATIC_DIR` | No | the console shipped with the server | Serve a different console build |
309
425
  | `OMNI_LOG_LEVEL` | No | `info` | Stdout threshold: `debug`, `info`, `warn`, or `error` |
310
426
  | `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 |
427
+ | `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
428
 
312
429
  Gateway events are written to stdout as one greppable line each: process lifecycle, OAuth
313
430
  refreshes, quota probes, failover, and errors.
@@ -330,6 +447,193 @@ environment. Edit it with `omni settings set` or in the console.
330
447
  `.env.example` in the repository documents the optional provider
331
448
  client-identity overrides.
332
449
 
450
+ ## Recording bodies
451
+
452
+ By default the gateway records no prompts and no responses. If you need them for
453
+ an incident — to see what a client actually sent, or what a provider actually
454
+ returned — capture is opt-in and takes **two independent keys, both required**:
455
+
456
+ 1. `OMNI_BODY_LOGGING_ALLOWED=1` in the installation's `.env`, read at boot.
457
+ 2. The **Capture request and response bodies** setting, in the console's
458
+ Settings screen or `omni settings set bodyLoggingEnabled true`. Off by
459
+ default. Raw SSE frames are the separate, far larger
460
+ `bodyLoggingCaptureStreamChunks`.
461
+
462
+ Two keys, because an admin session on its own must not be able to start
463
+ recording your users' prompts. With the environment variable unset the setting
464
+ does nothing at all, and the console says so rather than letting you flip a
465
+ switch that silently no-ops. With the environment variable set you can turn
466
+ capture on and off mid-incident without restarting.
467
+
468
+ Turning capture off stops new capture. It does not delete what was already
469
+ written.
470
+
471
+ **Per-key opt-out.** A gateway key can be created with *Never record this key's
472
+ bodies* — `omni keys create --no-bodies` — and it is then never captured whatever
473
+ the setting says. Use it for a client whose payloads must not be retained. The
474
+ choice is made when the key is issued and cannot be reversed afterwards; reissue
475
+ the key instead. `omni keys list` and the console's Keys screen both show which
476
+ keys are exempt, so an audit does not have to go through the database.
477
+
478
+ **What is captured.** What arrived at `/v1/*` and what was returned, plus the
479
+ request and response of every provider attempt, in dispatch order. The client
480
+ request is the conversation *before* RTK compression and each attempt request is
481
+ the one *after* it, so an artifact is the only place you can read what a filter
482
+ actually removed. The console labels which side is which, and so does the CLI.
483
+
484
+ **Reading them.** Expand a row on the console's Logs screen, or from a terminal:
485
+
486
+ ```bash
487
+ omni bodies req_550e8400-… # the frame: state, size, one line per attempt
488
+ omni bodies req_550e8400-… --full # the payloads themselves
489
+ omni bodies req_550e8400-… --json # the artifact, for a script
490
+ ```
491
+
492
+ **The bare command withholds the bodies and prints only the frame.** Every other
493
+ CLI read prints everything it has; this one prints conversations, and whoever
494
+ runs it during an incident is usually sharing that screen. Asking costs one
495
+ flag; printing by default costs a prompt corpus in someone's scrollback,
496
+ silently. The frame still gives you the state, capture time, size on disk, any
497
+ truncation, and each attempt's provider and byte counts — labelled `pre-RTK` for
498
+ the client request and `post-RTK` for attempts, because they are not the same
499
+ payload.
500
+
501
+ A request with no artifact is an answer rather than an error, and the three
502
+ answers are different: `not captured` means capture was not running, `captured,
503
+ then lost` means retention or the row cap has been through, and `captured, but
504
+ unreadable` means the file is there but will not decrypt — usually a changed
505
+ `OMNI_ENCRYPTION_KEY`.
506
+
507
+ There is no CLI command to delete a captured body. Retention, the row cap, and
508
+ the orphan sweep are the gateway's; a second path that erases forensic evidence
509
+ on request is a way to lose an incident record.
510
+
511
+ **What is never captured.** Headers, at any layer — every provider authenticates
512
+ through headers, so that is where the tokens are. That one is a guarantee: the
513
+ capture layer is never handed a header list, so no provider, present or future,
514
+ can opt its own in.
515
+
516
+ **Masking is best-effort, and a body corpus is sensitive even after it.** Bodies
517
+ are masked before they are written, replacing bearer tokens, `sk-`/`ak-`/`pk-`
518
+ prefixed keys, the well-known vendor prefixes (`ghp_` and the rest of GitHub's,
519
+ `github_pat_`, `AIza`, `GOCSPX-`, `xai-`), and any long opaque token with elided
520
+ forms. Two things follow, and both matter before you turn capture on:
521
+
522
+ - It costs fidelity. The length rule has no idea what it is looking at, so it
523
+ also elides base64 image data, content hashes, and minified source. That is
524
+ deliberate: a corpus that leaks a live credential is the worse failure.
525
+ - It does not catch everything. The length rule is tuned to base64url, so a
526
+ standard-base64 secret or an AWS secret access key can slip through it on a
527
+ `+` or a `/`, and a credential shorter than forty-one characters or exactly
528
+ forty characters long — an Azure OpenAI key, for instance — is out of its reach
529
+ entirely. The prefix rules exist precisely because the length rule cannot be
530
+ the whole answer, and between them they are a reduction in exposure, not a
531
+ guarantee of none.
532
+
533
+ Treat the artifact tree as you would treat the prompts themselves: it is
534
+ encrypted at rest, it belongs on a volume you control, and it is not something to
535
+ copy into a ticket.
536
+
537
+ Nothing changes about stdout. Prompts and responses never reach the log, the
538
+ journal, or the Console screen; capture is a separate encrypted store.
539
+
540
+ **Where it goes.** `request_bodies/YYYY/MM/DD/<requestId>.json.enc` beside the
541
+ database file, encrypted with AES-256-GCM under `OMNI_ENCRYPTION_KEY`, the same
542
+ key as your provider credentials. Copies taken without the key yield nothing.
543
+ Changing the key invalidates every artifact already written.
544
+
545
+ **Bounds.** Two limits, because either alone fails:
546
+
547
+ - Bodies expire on the same **log retention** window as request rows, swept
548
+ hourly, file and row deleted together.
549
+ - A hard cap of **100,000 body rows**, oldest pruned first. The window is what
550
+ you reason about; the row cap is what actually bounds disk, because a week's
551
+ window over sustained traffic bounds nothing.
552
+
553
+ Individual payloads are bounded structurally rather than by byte offset — strings
554
+ past 64 KB, arrays to their last 24 items, nesting past 6 levels, objects to 80
555
+ keys — so a stored artifact is always valid JSON. An artifact still over 512 KB
556
+ after that has its bodies replaced by a marker recording why.
557
+
558
+ **Sizing.** 512 KB is a *plaintext* cap and encryption emits hex, so one artifact
559
+ can reach ~1 MB on disk: with the 100,000-row cap the corpus worst case is about
560
+ **100 GB**, not 50. Most artifacts are a few kilobytes, but that is the number to
561
+ size a volume against. The same cap applies per body held in memory while a
562
+ request is in flight, one per side per attempt — so ~512 KB × (attempts + 1) per
563
+ captured request. Multiply by your concurrency before enabling this on a small
564
+ box.
565
+
566
+ ## Snapshots and restore
567
+
568
+ The console's Database screen reports what this installation occupies — the
569
+ database file, its write-ahead log, the captured-body tree, and the free pages a
570
+ compaction would give back — and takes snapshots. `omni db stats` prints the same
571
+ figures.
572
+
573
+ **What a snapshot is.** One self-contained SQLite file, written into a
574
+ `snapshots/` directory beside the database. The write-ahead log is folded in, so
575
+ there is nothing else to copy alongside it, and taking one is safe while the
576
+ gateway is running: it reads through SQLite rather than copying bytes off disk.
577
+
578
+ **What it is not.** The sibling `request_bodies/` tree is excluded, always. A
579
+ snapshot is never a prompt corpus, and its size tracks your configuration and
580
+ usage history rather than your traffic. The cost is that a restore leaves the
581
+ captured-body tree out of step with the table: files the restored database has no
582
+ row for are collected by the hourly sweep, and a row whose file is gone reads back
583
+ as `captured, then lost`.
584
+
585
+ **A snapshot does carry secrets.** Your provider credentials are in it, encrypted,
586
+ along with your gateway key hashes. They are inert only because
587
+ `OMNI_ENCRYPTION_KEY` is not in the file — it lives in the installation's `.env`.
588
+ So treat a downloaded snapshot exactly as you treat the database itself: anyone
589
+ holding both the file and the key holds your provider accounts. Downloads are
590
+ never cached, and each one is recorded in the gateway's log.
591
+
592
+ **Retention** bounds the directory: at most `keepLatest` snapshots are kept, and
593
+ nothing older than `maxAgeDays` — 5 and 30 by default. Both bounds have to pass,
594
+ so an old snapshot goes even while the count is under the limit, and the newest is
595
+ always kept whatever the numbers say. Pruning runs when a snapshot is taken rather
596
+ than on a timer, so a quiet installation keeps what it already has. **Edit the
597
+ policy on the Database screen**, not on Settings: it is deliberately not part of
598
+ the settings form, so a settings save from a client that has never heard of
599
+ retention leaves your policy alone instead of resetting it.
600
+
601
+ The copy taken automatically on the way into a restore is exempt from retention.
602
+ It is the undo.
603
+
604
+ **Restoring from the console** happens inside the running gateway. Client traffic
605
+ on `/v1/*` is refused with a retryable 503 while the file is replaced; `/api/*`
606
+ and `/health` keep answering, so you can watch the restore and hear how it ended.
607
+ Restoring a database that carries a different console password signs every session
608
+ out; restoring this installation's own snapshot does not. The screen also uploads
609
+ a database file from elsewhere, up to 2 GiB, which is how you move an installation
610
+ to another machine — bring `OMNI_ENCRYPTION_KEY` with it, or the credentials in it
611
+ are unreadable. Either way the file is integrity-checked before anything is
612
+ touched, and a copy of what was there is taken first.
613
+
614
+ **A restore ends by rebuilding the hourly usage rollup, and that step blocks.**
615
+ The rollup is what rate limits count their 5h and 1w windows from, and no file an
616
+ operator hands over says whether its counters agree with its rows — so it is
617
+ recomputed rather than trusted. `bun:sqlite` is synchronous, so the grouped scan
618
+ holds the event loop: roughly 0.4 s per 500k request-log rows, 1.6 s per 2M, and
619
+ about 6.5 s at 8M. `/api/*` and `/health` do not answer during it. It is the last
620
+ thing a restore does, after the swap has already succeeded, and a failure is
621
+ logged rather than raised — the database is live either way, and `omni doctor`
622
+ reports a rollup that disagrees with its rows.
623
+
624
+ **`omni db restore <id>` refuses while a gateway is running** against that
625
+ installation, and there is no override flag. A second process can open its own
626
+ handle but cannot quiesce the gateway's, and moving the file out from under a live
627
+ SQLite connection corrupts the database you were trying to rescue. Run `omni stop`
628
+ first, or restore from the console, which swaps the file behind its own quiesce
629
+ latch.
630
+
631
+ **Compaction.** `omni db vacuum`, or the console's equivalent, rewrites the
632
+ database and reclaims the pages deletion left free. It holds SQLite's write lock
633
+ for the rewrite, so a busy gateway stalls on its writes until it finishes — but
634
+ nothing is lost by running it live, and it reports what it actually gave back to
635
+ the filesystem.
636
+
333
637
  ## Docker
334
638
 
335
639
  ```bash
@@ -346,31 +650,52 @@ The container listens on `0.0.0.0:9000` and keeps its database at
346
650
  serves the APIs and returns 404 for the console. Use the CLI or the control API
347
651
  against it, or install the npm package if you want the console.
348
652
 
653
+ Give the container a restart policy — `--restart unless-stopped` — if you want a
654
+ restart request to bring it back. A container cannot read its own policy, so
655
+ without one an exit is simply the end of the installation until you start it
656
+ again from the host.
657
+
349
658
  ## Scope and limits
350
659
 
351
660
  Worth knowing before you deploy it:
352
661
 
353
662
  - **One machine, one operator.** No multi-tenancy, no clustering, no shared
354
- state. Rate limits are counted per process and reset when it restarts.
663
+ state. The `1m` window and the `concurrency` gauge are counted in the gateway
664
+ process and reset when it restarts; `5h` and `1w` are counted from the database
665
+ and survive one. Two gateways over one database would not see each other's
666
+ short-window counts.
355
667
  - **Two grains of usage history.** Detailed request logs are pruned after 30
356
668
  days by default; a daily rollup is kept for 400 days. A day is your host's
357
669
  local midnight, fixed when the row is written.
670
+ - **Body capture is forensics, not an archive.** It is off unless you turn it on
671
+ with both keys, it expires on the request-log window, and it is capped at
672
+ 100,000 rows. It is not a searchable prompt history and there is no CLI for it.
673
+ - **Snapshots are manual, and local.** Nothing takes one on a schedule and there
674
+ is no off-host target; retention bounds what you have taken, and a restore
675
+ always takes one first. Copy them somewhere else yourself if the disk failing
676
+ is what you are guarding against.
358
677
  - **Quota readings come from the providers**, and their usage endpoints are
359
678
  undocumented. An account with nothing reported is treated as unknown, never
360
679
  as unlimited.
361
680
  - **The gateway does not know which model accepts which request shape.** An
362
681
  unsupported combination surfaces as the provider's own 400 rather than being
363
682
  caught earlier.
364
- - Not in scope for version 1: semantic caching, billing, prompt storage,
365
- horizontal scaling.
683
+ - Not in scope for version 1: semantic caching, billing, horizontal scaling.
366
684
 
367
685
  ## Security
368
686
 
369
687
  - Treat `OMNI_ENCRYPTION_KEY`, gateway keys, and the SQLite file as secrets.
370
688
  Anyone with the file *and* the key has your provider credentials.
371
689
  - Prompts and responses are never logged. Request logs hold metadata and token
372
- counts only.
690
+ counts only, and no body ever reaches stdout, the journal, or the Console
691
+ screen. Bodies are stored only if you opt in to
692
+ [body capture](#recording-bodies), which needs both an environment variable
693
+ and a setting, encrypts what it writes, and can be refused per key.
373
694
  - Gateway keys are stored as hashes. A lost key is reissued, not recovered.
695
+ - A [snapshot](#snapshots-and-restore) carries whatever the database does —
696
+ encrypted provider credentials, gateway key hashes — and is inert only because
697
+ `OMNI_ENCRYPTION_KEY` is not in it. Captured bodies are excluded, so a snapshot
698
+ is never a prompt corpus.
374
699
  - Secrets are never accepted on the command line — `omni` prompts for them or
375
700
  reads stdin — so they stay out of your shell history and the process table.
376
701
  - Behind a reverse proxy, set `OMNI_BASE_URL` to the public HTTPS origin so
@@ -383,8 +708,9 @@ Worth knowing before you deploy it:
383
708
  Contributing, or running from a checkout? See
384
709
  [ARCHITECTURE.md](ARCHITECTURE.md) for how the system fits together,
385
710
  [CLAUDE.md](CLAUDE.md) for the repository map, architectural boundaries, and
386
- conventions, and `docs/superpowers/specs/` for the design documents behind each
387
- feature.
711
+ conventions, [docs/adding-a-provider.md](docs/adding-a-provider.md) for the
712
+ provider checklist, and `docs/superpowers/specs/` for the design documents
713
+ behind each feature.
388
714
 
389
715
  ```bash
390
716
  git clone https://github.com/harismawan/omnigateway.git