omnigateway 0.2.1 → 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 (57) hide show
  1. package/README.md +426 -13
  2. package/bin/omni.js +7405 -3907
  3. package/gateway.js +5564 -1384
  4. package/package.json +1 -1
  5. package/public/assets/{Chip-C4X8tf5z.js → Chip-DbKk1ExR.js} +2 -2
  6. package/public/assets/Confirm-C5DYoI93.js +4 -0
  7. package/public/assets/CopyValue-CXjietiZ.js +27 -0
  8. package/public/assets/{Field-Ct4SeDZD.js → Field-B7GOsJf8.js} +8 -8
  9. package/public/assets/Lamp-QRSv-SqM.js +25 -0
  10. package/public/assets/{Meter-COdz0dwY.js → Meter-Dngwbpjo.js} +2 -2
  11. package/public/assets/Modal-DfltaSCz.js +82 -0
  12. package/public/assets/Rack-D1zBw9SW.js +151 -0
  13. package/public/assets/{Readout-Bl2kD9Tl.js → Readout--CQnPjoO.js} +4 -4
  14. package/public/assets/{States-D5Memn8D.js → States-CQFkw1UZ.js} +10 -10
  15. package/public/assets/{Table-DObRatbW.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-BVVpryIB.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-CZD2UpM-.js +148 -0
  26. package/public/assets/_app.settings-B2C1LQIF.js +46 -0
  27. package/public/assets/_app.usage-CDWUXtZy.js +83 -0
  28. package/public/assets/catalog-BBKm8Ih8.js +1 -0
  29. package/public/assets/dist-IkJ0qg-4.js +1 -0
  30. package/public/assets/index-ClU3Xqui.js +186 -0
  31. package/public/assets/login-E3gh2_Cl.js +36 -0
  32. package/public/assets/plus-DkO4vG-T.js +1 -0
  33. package/public/assets/queries-BJHk_wso.js +144 -0
  34. package/public/assets/reasons-Cb8yF4Oy.js +1 -0
  35. package/public/assets/shared-Bp35cgFf.js +84 -0
  36. package/public/assets/{trash-2-Da6cLgPn.js → trash-2-DTWWI3vA.js} +1 -1
  37. package/public/index.html +2 -2
  38. package/public/assets/Confirm-BnmH8Gn0.js +0 -4
  39. package/public/assets/CopyValue-B-uR4Js-.js +0 -27
  40. package/public/assets/Lamp-CBOwqG5K.js +0 -25
  41. package/public/assets/Modal-DMFUHQ-A.js +0 -82
  42. package/public/assets/Rack-DAbY9x1I.js +0 -151
  43. package/public/assets/Toggle-Dkjlvck5.js +0 -39
  44. package/public/assets/TokenBreakdown-oXP49ZTa.js +0 -31
  45. package/public/assets/_app-Brc2wQZm.js +0 -1
  46. package/public/assets/_app.accounts-I9RXUiyi.js +0 -54
  47. package/public/assets/_app.index-83Ig4FUL.js +0 -62
  48. package/public/assets/_app.keys-KeP6L4g_.js +0 -39
  49. package/public/assets/_app.logs-B5oh01Ls.js +0 -32
  50. package/public/assets/_app.models-BuOFMZUN.js +0 -144
  51. package/public/assets/_app.settings-BN436vI2.js +0 -38
  52. package/public/assets/_app.usage-C528VOia.js +0 -166
  53. package/public/assets/catalog-kJ53n_fc.js +0 -1
  54. package/public/assets/dist-CnzT-Ut2.js +0 -1
  55. package/public/assets/index-CE-KQ-ju.js +0 -172
  56. package/public/assets/login-2LpCZOtB.js +0 -33
  57. package/public/assets/queries-1zRLkX-Q.js +0 -144
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  One endpoint in front of the AI accounts you already pay for.
4
4
 
5
5
  OmniGateway is a self-hosted gateway that speaks the Anthropic and OpenAI APIs
6
- and answers them using your own Anthropic, OpenAI, and Kimi Coding
6
+ and answers them using your own Anthropic, OpenAI, Kimi Coding, Kilo, and xAI
7
7
  subscriptions. Point any compatible client at it, ask for a model you defined,
8
8
  and the gateway picks an account that can serve it — falling back to another
9
9
  when one is rate-limited, expired, or out of quota.
@@ -29,7 +29,7 @@ omni start
29
29
  provider dispatch, preserve errors and non-tool-result content, and default off.
30
30
  - **Routes across your accounts.** Define a virtual model like `fast` or
31
31
  `smart` with several targets; the gateway ranks them by tier, health,
32
- remaining quota, cost, and latency.
32
+ remaining quota, cost, latency, and current load.
33
33
  - **Fails over.** A rate-limited or broken account is skipped, its circuit
34
34
  breaker opens, and the next candidate is tried — before the response starts
35
35
  streaming.
@@ -39,18 +39,89 @@ 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
 
53
+ ## How it is built
54
+
55
+ A Bun workspace monorepo. The layering is deliberate and enforced by what each
56
+ package is allowed to import: a provider-neutral core at the bottom, provider
57
+ knowledge in one place, pure routing above that, and every side effect pushed up
58
+ into the gateway process.
59
+
60
+ ```mermaid
61
+ graph TD
62
+ subgraph frontends[Front ends]
63
+ gateway["apps/gateway<br/><i>Elysia server, dispatch, loops</i>"]
64
+ cli["apps/cli<br/><i>the omni binary</i>"]
65
+ dashboard["apps/dashboard<br/><i>React console</i>"]
66
+ end
67
+
68
+ control["@omni/control<br/><i>every operator action;<br/>no HTTP, argv or terminal</i>"]
69
+ router["@omni/router<br/><i>pure ranking;<br/>no I/O, no timers</i>"]
70
+ store["@omni/store<br/><i>SQLite + field encryption</i>"]
71
+ providers["@omni/providers<br/><i>adapters, wire codecs,<br/>catalog, HTTP client</i>"]
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>"]
74
+ ir["@omni/ir<br/><i>domain model — depends on nothing</i>"]
75
+
76
+ gateway --> control
77
+ gateway --> rtk
78
+ gateway --> ratelimit
79
+ cli --> control
80
+ dashboard -. "types + catalog only" .-> providers
81
+ dashboard -. "types only" .-> store
82
+ control --> router
83
+ control --> ratelimit
84
+ router --> store
85
+ router --> providers
86
+ store --> rtk
87
+ store --> ratelimit
88
+ providers --> ir
89
+ rtk --> ir
90
+ store --> ir
91
+ ```
92
+
93
+ Arrows read *depends on*, and the direction never reverses. Two rules do most of
94
+ the work: `@omni/ir` is side-effect free and imports nothing, and `@omni/router`
95
+ is a pure function — no network, no database, no timers. Everything that has to
96
+ touch the world is pushed up into the gateway process.
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
+
106
+ The dashboard's edges are dotted because they are type-level only.
107
+ `@omni/providers/catalog` is deliberately kept import-free so model lists can be
108
+ bundled into the browser without dragging in the HTTP client.
109
+
110
+ The gateway and the CLI are two front ends over the same `@omni/control`
111
+ functions. The CLI does not call the running server's API — it opens the same
112
+ SQLite file directly, which is why it still works when the gateway is down.
113
+
114
+ **[ARCHITECTURE.md](ARCHITECTURE.md)** goes a level deeper: a request traced end
115
+ to end, how routing ranks and excludes accounts, the commit point that decides
116
+ whether failover is still possible, the provider adapter shape, the database
117
+ schema and its encryption boundary, and the background loops.
118
+
48
119
  ## Requirements
49
120
 
50
121
  - [Bun](https://bun.sh/) 1.4 or later. Bun is the runtime, not just the
51
122
  installer, so a Node-only machine cannot run OmniGateway.
52
123
  - A directory that persists, for the SQLite database.
53
- - At least one Anthropic, OpenAI, or Kimi Coding account to connect.
124
+ - At least one Anthropic, OpenAI, Kimi Coding, Kilo, or xAI account to connect.
54
125
 
55
126
  ## Install
56
127
 
@@ -86,9 +157,18 @@ omni start # serves the API and the console on 127.0.0.1:9000
86
157
  Connect an account. The CLI prints a URL to open, and waits:
87
158
 
88
159
  ```bash
89
- omni connect anthropic # or: openai, kimi
160
+ omni connect anthropic # or: openai, kimi, kilo, grok
90
161
  ```
91
162
 
163
+ Every provider also takes a plain API key, if that is what you hold rather than
164
+ a subscription. `custom` takes nothing else:
165
+
166
+ ```bash
167
+ omni credentials add-key anthropic # prompts for the key, or reads stdin
168
+ ```
169
+
170
+ The console offers the same choice per provider on the Connect dialog.
171
+
92
172
  Define a virtual model your clients will ask for, seeding its pricing and
93
173
  capabilities from the built-in catalog:
94
174
 
@@ -97,12 +177,70 @@ omni models catalog # what is available
97
177
  omni models put fast --from-catalog anthropic:claude-sonnet-5
98
178
  ```
99
179
 
180
+ **A note on grok pricing.** xAI charges by request size: at or above 200K
181
+ context the rate roughly doubles, and the higher rate applies to *every token
182
+ in the request*, not just the tokens past the mark. A target holds one flat
183
+ price, so the catalog carries xAI's sub-200K figures and long-context traffic
184
+ is reported cheaper than it was billed. Catalog pricing is only the default a
185
+ new target starts from — if you run grok at long context, edit the saved
186
+ target's price to match the tier you are actually paying.
187
+
188
+ **A note on `kilo-auto/*` pricing.** Kilo's `frontier`, `balanced`, and
189
+ `efficient` routers choose an upstream model per request, and Kilo states no
190
+ rate for them. The catalog records zero, which the router reads as *unpriced*
191
+ and leaves out of its cost ranking — the same stored figure `kilo-auto/free`
192
+ carries because it genuinely is free. So a `kilo-auto` target seeded from the
193
+ catalog is not free, it is unranked: cost never counts for or against it. If you
194
+ want one ranked against your other accounts, set a real `costPerMTok` on the
195
+ saved target for the tier you expect it to land in.
196
+
100
197
  Mint a key for your client. **It is printed once and stored only as a hash:**
101
198
 
102
199
  ```bash
103
200
  omni keys create --label laptop
104
201
  ```
105
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
+
106
244
  Now use it:
107
245
 
108
246
  ```bash
@@ -143,6 +281,36 @@ once.
143
281
  Most tools that accept a custom base URL work unchanged: set it to
144
282
  `http://127.0.0.1:9000` and use a gateway key where the provider key goes.
145
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
+
146
314
  ### Tools and routing
147
315
 
148
316
  Two kinds of tool behave differently, and the difference decides where a
@@ -170,21 +338,31 @@ carries the header through but does not add one on your behalf.
170
338
  command takes `--json` for scripting, and `--root <path>` to manage an
171
339
  installation other than the default.
172
340
 
341
+ `--root` names the installation, so an `OMNI_DB_PATH` exported in your shell is
342
+ ignored alongside it and said so on stderr; that root's own `.env` still decides.
343
+ Use `--db <path>` to point one command somewhere else.
344
+
173
345
  | | |
174
346
  | --- | --- |
175
347
  | `omni status` | the gateway, its accounts, and their quota, on one screen |
176
348
  | `omni start` / `stop` / `restart` | run the gateway; `--foreground` attaches it to your terminal |
177
349
  | `omni doctor` | which installation it resolved, and whether it can act on it |
178
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` |
179
352
  | `omni console` | the gateway process's own output: boot, refreshes, quota, errors |
180
353
  | `omni usage` | spend and tokens, by provider, model, key, or day |
354
+ | `omni quota` | provider quota per window: use, burn rate, and when it runs out |
181
355
  | `omni connect <provider>` | authorize an account from the terminal |
182
356
  | `omni credentials …` | list, show, enable, disable, retier, refresh, remove |
183
357
  | `omni models …` | list, show, put, remove, `dry-run`, `catalog` |
184
358
  | `omni keys …` | list, create, revoke |
185
- | `omni settings get` / `set` | routing weights, retention, deadlines |
359
+ | `omni settings get` / `set` | routing weights, retention, deadlines, and the runtime switches |
186
360
  | `omni admin set-password` | change the console password |
187
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 |
188
366
 
189
367
  Two worth knowing:
190
368
 
@@ -209,6 +387,29 @@ start` supervises the process itself with a pidfile under
209
387
  `~/.local/state/omnigateway`. Either way, `omni start` returns only once
210
388
  `/health` actually answers.
211
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
+
212
413
  ## Configuration
213
414
 
214
415
  Configuration is environment variables, read from the installation's `.env`:
@@ -223,6 +424,7 @@ Configuration is environment variables, read from the installation's `.env`:
223
424
  | `OMNI_STATIC_DIR` | No | the console shipped with the server | Serve a different console build |
224
425
  | `OMNI_LOG_LEVEL` | No | `info` | Stdout threshold: `debug`, `info`, `warn`, or `error` |
225
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) |
226
428
 
227
429
  Gateway events are written to stdout as one greppable line each: process lifecycle, OAuth
228
430
  refreshes, quota probes, failover, and errors.
@@ -245,6 +447,193 @@ environment. Edit it with `omni settings set` or in the console.
245
447
  `.env.example` in the repository documents the optional provider
246
448
  client-identity overrides.
247
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
+
248
637
  ## Docker
249
638
 
250
639
  ```bash
@@ -261,31 +650,52 @@ The container listens on `0.0.0.0:9000` and keeps its database at
261
650
  serves the APIs and returns 404 for the console. Use the CLI or the control API
262
651
  against it, or install the npm package if you want the console.
263
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
+
264
658
  ## Scope and limits
265
659
 
266
660
  Worth knowing before you deploy it:
267
661
 
268
662
  - **One machine, one operator.** No multi-tenancy, no clustering, no shared
269
- 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.
270
667
  - **Two grains of usage history.** Detailed request logs are pruned after 30
271
668
  days by default; a daily rollup is kept for 400 days. A day is your host's
272
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.
273
677
  - **Quota readings come from the providers**, and their usage endpoints are
274
678
  undocumented. An account with nothing reported is treated as unknown, never
275
679
  as unlimited.
276
680
  - **The gateway does not know which model accepts which request shape.** An
277
681
  unsupported combination surfaces as the provider's own 400 rather than being
278
682
  caught earlier.
279
- - Not in scope for version 1: semantic caching, billing, prompt storage,
280
- horizontal scaling.
683
+ - Not in scope for version 1: semantic caching, billing, horizontal scaling.
281
684
 
282
685
  ## Security
283
686
 
284
687
  - Treat `OMNI_ENCRYPTION_KEY`, gateway keys, and the SQLite file as secrets.
285
688
  Anyone with the file *and* the key has your provider credentials.
286
689
  - Prompts and responses are never logged. Request logs hold metadata and token
287
- 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.
288
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.
289
699
  - Secrets are never accepted on the command line — `omni` prompts for them or
290
700
  reads stdin — so they stay out of your shell history and the process table.
291
701
  - Behind a reverse proxy, set `OMNI_BASE_URL` to the public HTTPS origin so
@@ -295,9 +705,12 @@ Worth knowing before you deploy it:
295
705
 
296
706
  ## Development
297
707
 
298
- Contributing, or running from a checkout? See [CLAUDE.md](CLAUDE.md) for the
299
- repository map, architectural boundaries, and conventions, and
300
- `docs/superpowers/specs/` for the design documents behind each feature.
708
+ Contributing, or running from a checkout? See
709
+ [ARCHITECTURE.md](ARCHITECTURE.md) for how the system fits together,
710
+ [CLAUDE.md](CLAUDE.md) for the repository map, architectural boundaries, and
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.
301
714
 
302
715
  ```bash
303
716
  git clone https://github.com/harismawan/omnigateway.git