cachegate 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,428 +1,505 @@
1
- # cachegate
2
-
3
- <!-- "OWNER" below is a placeholder - fill in the real GitHub org/username
4
- at step 15, same placeholder used in .github/ISSUE_TEMPLATE/config.yml -->
5
- [![Tests](https://github.com/OWNER/cachegate/actions/workflows/test.yml/badge.svg)](https://github.com/OWNER/cachegate/actions/workflows/test.yml)
6
- [![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
7
-
8
- A self-hostable, OpenAI-compatible proxy that routes LLM requests to the
9
- cheapest currently-healthy provider, caches responses both exactly and
10
- semantically, and tracks cost and latency per call.
11
-
12
- ## Why this instead of LiteLLM / Portkey / OpenRouter?
13
-
14
- Those are all excellent, and this doesn't try to out-feature them (140+
15
- providers, a huge ecosystem, hosted enterprise plans). The niche this
16
- fills instead:
17
-
18
- - **Self-hosted first** — your prompts and provider keys never leave
19
- your own infrastructure. No account, no telemetry, no hosted
20
- dependency to go down.
21
- - **Semantic cache included**, not just exact-match most lightweight
22
- self-hosted options only hash-match identical requests. (LiteLLM does
23
- now ship a more sophisticated vector-indexed semantic cache than this
24
- project's brute-force cosine scan stated plainly, not glossed over;
25
- see "Two kinds of cache hit" below for what this one actually does.)
26
- - **Node.js/TypeScript-native** — most comparable gateways are Python;
27
- this fits directly into a JS/TS stack with no cross-language bridge.
28
- - **Small and embeddable** a handful of files, no framework beyond
29
- Express, easy to read end to end and drop into an existing app's own
30
- backend rather than standing up a separate service.
31
- - **Honest numbers.** Caching alone typically saves 20-45% on LLM spend;
32
- add routing and well-tuned traffic can reach 47-90%. Not the inflated
33
- 86-95% figures some vendors quote — real ranges, from real benchmarks.
34
-
35
- ## What this is NOT
36
-
37
- - **Not a hosted service.** There's no cloud offering, no login, no
38
- billing, no multi-tenant key custody herethis is the engine you
39
- run yourself. If you want that instead, that's a separate, closed
40
- product built on top of this same engine — not a fork of this one,
41
- and not something this repository will ever grow into. This project
42
- intentionally doesn't ship the pieces (billing, multi-tenant key
43
- custody, a login system) that a competing hosted offering would need,
44
- and isn't looking for PRs that add them (see `CONTRIBUTING.md`'s
45
- scope note) — not because the license forbids it (MIT permits
46
- exactly that see `LICENSE`), but because it's not what this project
47
- is for.
48
- - **Not a 140-provider gateway.** Anthropic and OpenAI today (see
49
- "Features" below for the honest current gap against a wider pitch).
50
- - **Not a vector-indexed semantic cache** (yet) — see "Two kinds of
51
- cache hit" for the real, disclosed scale limit.
52
-
53
- The last two are real gaps worth a PR. The first is a boundary, not a
54
- gap see `CONTRIBUTING.md` before opening one for it.
55
-
56
- ## Run it
57
-
58
- **Zero-clone** (once published to npm — see `OPEN_SOURCE_ROADMAP.md` step 17):
59
- ```bash
60
- npx cachegate
61
- ```
62
- Reads config from `.env` in the current directory, same as every other
63
- option below there's no separate config path for this one.
64
-
65
- **Standalone** (this repo on its own):
66
- ```bash
67
- git clone <this-repo-url>
68
- cd <repo-directory>
69
- npm install
70
- ```
71
-
72
- **Embedded** (copied into an existing app's own backend, alongside its
73
- other services): copy this directory into your project, then run the
74
- same commands from inside it.
75
-
76
- ```bash
77
- npm install
78
- ```
79
-
80
- **Docker:**
81
- ```bash
82
- docker build -t cachegate .
83
- docker run -p 4000:4000 --env-file .env cachegate
84
- ```
85
- The image runs as a non-root user, and its `HEALTHCHECK` calls the same
86
- `GET /health` endpoint documented below `docker ps` shows `healthy`/
87
- `unhealthy` once the container's been up for a few seconds. **Redis is
88
- not bundled in the image** — point `REDIS_URL` in your `.env` at an
89
- existing Redis instance (a sibling container on the same Docker
90
- network, or a managed one); without it the exact-match and semantic
91
- caches are disabled cleanly (see "Features" below), not a startup
92
- failure.
93
-
94
- Create or edit your local `.env` file (do **not** overwrite an existing one):
95
-
96
- ```text
97
- PORT=4000
98
- MODEL_ROUTER_INTERNAL_KEY=your-random-internal-key
99
- ANTHROPIC_API_KEY=your-real-key-here
100
- # Optional:
101
- # OPENAI_API_KEY=your-openai-key-here
102
- # REDIS_URL=redis://localhost:6379
103
- ```
104
-
105
- See `.env.example` for the full list of options (semantic cache
106
- tuning, routing strategy, metrics storage, rate limits) — the model
107
- itself is named per-request in the API call, not configured here.
108
-
109
- `MODEL_ROUTER_INTERNAL_KEY` is required - the server refuses to start
110
- without it, on purpose (see "Auth" below). For a throwaway local
111
- instance only, you can skip it and set `ALLOW_INSECURE_LOCAL_DEV=true`
112
- instead.
113
-
114
- ```bash
115
- npm start
116
- ```
117
-
118
- `.env` is gitignored. `.env.example` is only a reference template.
119
-
120
- ## Usage
121
-
122
- Direct dispatch - name a specific provider's model, same as calling that
123
- provider yourself:
124
-
125
- ```bash
126
- curl http://localhost:4000/v1/chat/completions \
127
- -H "Content-Type: application/json" \
128
- -H "Authorization: Bearer your-random-internal-key" \
129
- -d '{
130
- "model": "claude-sonnet-4-5-20250929",
131
- "max_tokens": 1024,
132
- "messages": [{"role": "user", "content": "Say hello"}]
133
- }'
134
- ```
135
-
136
- Routed dispatch - name a capability tier instead, and the router picks
137
- the cheapest currently-healthy provider for it:
138
-
139
- ```bash
140
- curl http://localhost:4000/v1/chat/completions \
141
- -H "Content-Type: application/json" \
142
- -H "Authorization: Bearer your-random-internal-key" \
143
- -d '{
144
- "model": "router:fast-cheap",
145
- "max_tokens": 1024,
146
- "messages": [{"role": "user", "content": "Say hello"}]
147
- }'
148
- ```
149
-
150
- `GET /health` lists the configured tiers and the active routing
151
- strategy. Tiers are defined in `router.js` (`DEFAULT_TIERS`) and can be
152
- overridden per deployment via the `ROUTER_TIERS_JSON` env var; the
153
- strategy is `ROUTER_STRATEGY` (`cost` / `latency` / `latency-guarded-cost`,
154
- default `cost`) - see "Where this leaves things" below for what each
155
- one actually does.
156
-
157
- Streamed dispatch - add `"stream": true` to either form above and get
158
- back SSE chunks instead of one JSON body (see "Streaming" below for
159
- scope):
160
-
161
- ```bash
162
- curl -N http://localhost:4000/v1/chat/completions \
163
- -H "Content-Type: application/json" \
164
- -H "Authorization: Bearer your-random-internal-key" \
165
- -d '{
166
- "model": "claude-sonnet-4-5-20250929",
167
- "max_tokens": 1024,
168
- "stream": true,
169
- "messages": [{"role": "user", "content": "Say hello"}]
170
- }'
171
- ```
172
-
173
- ## Auth
174
-
175
- Every `/v1/*` and `/stats` request needs `Authorization: Bearer
176
- <MODEL_ROUTER_INTERNAL_KEY>`. If the key isn't set, the server refuses
177
- to start at all rather than falling open - an earlier version treated a
178
- missing key as "no auth enforced," which is exactly the kind of thing
179
- that turns into an unauthenticated proxy sitting in front of real
180
- provider API keys the moment someone forgets to set it. Set
181
- `ALLOW_INSECURE_LOCAL_DEV=true` to explicitly opt into running with no
182
- auth, for local development only.
183
-
184
- ## Features
185
-
186
- - OpenAI-compatible `/v1/chat/completions` endpoint - direct dispatch to
187
- a named provider model, or routed dispatch via a `router:` capability
188
- tier (cheapest currently-healthy candidate, by estimated cost; see
189
- `router.js`).
190
- - **`stream: true` works** for plain text content, on both providers,
191
- including replaying a cache hit (exact or semantic) as a stream so a
192
- streaming caller still gets the caching benefit. See "Streaming"
193
- below for the real scope boundary (tool-call streaming isn't
194
- included) and the cost-tracking detail it depends on.
195
- - Anthropic and OpenAI providers. (Not yet: Gemini, Groq, local models -
196
- a real gap against the two-provider skeleton's original pitch.)
197
- - Redis-backed exact-match response cache by content hash - the first,
198
- free, zero-risk check on every request.
199
- - A semantic cache on top of it, for near-duplicate prompts the exact
200
- hash can't catch (a paraphrase, reordered context). Requires
201
- `OPENAI_API_KEY` (the only embedding backend right now, regardless of
202
- which provider actually answers the chat request) and Redis; disabled
203
- cleanly if either is missing. Tool-calling requests are never
204
- semantically cached (see `semanticCache.js`). `GET /health` reports
205
- `semantic_cache_enabled`; `GET /stats` reports exact and semantic hit
206
- rates **separately**, not blended - see "Two kinds of cache hit"
207
- below for why that distinction matters.
208
- - Per-request cost and latency tracking, persisted to a local JSONL log
209
- (`metrics.js`) so routing decisions and `GET /stats` have real
210
- history to work from, not just a number thrown away after each
211
- response.
212
- - Rate limiting on `/v1/*` (`RATE_LIMIT_MAX` requests per
213
- `RATE_LIMIT_WINDOW_MS`, defaults 60/60s) - this proxy sits in front of
214
- paid, metered keys, so an unbounded client has no ceiling otherwise.
215
- - `GET /health` for monitoring (public, no auth) and `GET /stats` for a
216
- quick record-count-windowed aggregate snapshot (auth required).
217
- - **A cost dashboard** at `GET /dashboard` - a static page (no auth
218
- itself; its own JS asks for the internal key and stores it in
219
- localStorage, then calls the authenticated data endpoint below) with
220
- KPI tiles, cost-over-time, requests-by-outcome, and cost-by-provider
221
- charts, a 7/14/30-day range picker, a table-view twin for every chart,
222
- and a toggleable 30-second auto-refresh (paused while the tab isn't
223
- visible). Backed by `GET /dashboard/data` (auth required), which computes
224
- everything from one calendar-windowed pass over the metrics log so the
225
- tiles, charts, and provider table can never disagree with each other.
226
- See "Two kinds of cache hit" below and "Cost dashboard" further down
227
- for the real tradeoffs and limitations.
228
- - Automated tests (`npm test`, Node's built-in test runner) covering
229
- auth, request validation, routing decisions (including the unhealthy-
230
- provider fallback), and the metrics store. They don't call a real
231
- provider API - that needs live keys and real spend, out of scope for
232
- this suite.
233
-
234
- ## Two kinds of cache hit - why they're reported separately
235
-
236
- An **exact** hit means this exact request (same model, same messages,
237
- same params) was seen before - the cached response is guaranteed
238
- correct for it. A **semantic** hit means a *different* request scored
239
- above a similarity threshold against something cached before - the
240
- router's best guess that they want the same answer, not proof they do.
241
- Blending those into one "cache hit rate" number is exactly the failure
242
- mode this project's own market research flagged in vendor marketing:
243
- inflated headline hit-rate claims that don't hold up against real
244
- production numbers. `GET /stats` reports `cache_hit_rate.exact`,
245
- `.semantic`, and `.combined` as three separate numbers so nobody has to
246
- take that on faith.
247
-
248
- Practical tradeoff worth stating plainly: the semantic cache is not
249
- free to run. Every request that misses the exact cache costs one
250
- embedding call to check the semantic cache (`SEMANTIC_CACHE_THRESHOLD`,
251
- default `0.93`, tunable) - whether or not it finds a match - plus
252
- another embedding call to store the eventual answer. That's real cost
253
- and latency on every miss, in exchange for a chance at skipping a much
254
- larger completion call on a future near-duplicate. It's worth it when
255
- near-duplicate traffic is common; it's pure overhead when it isn't. Set
256
- `SEMANTIC_CACHE_ENABLED=false` to disable it outright while keeping the
257
- exact-match cache and `OPENAI_API_KEY` for other things.
258
-
259
- Storage is a plain Redis list per model, capped at
260
- `SEMANTIC_CACHE_MAX_CANDIDATES` (default 200) - a lookup does a
261
- brute-force cosine-similarity scan over that list in Node, not an
262
- indexed vector search. No RediSearch or vector-search Redis module is
263
- assumed (most self-hosted Redis, including Render's managed Redis,
264
- doesn't have one). That's fine at single-instance, self-hosted volume;
265
- it is not built to scale past that cap. See `semanticCache.js` for the
266
- full reasoning.
267
-
268
- ## Streaming
269
-
270
- `stream: true` forwards a real, incremental, token-by-token response
271
- from either provider, framed as OpenAI-compatible SSE chunks
272
- (`data: {...}\n\n`, ending `data: [DONE]\n\n`). A few things worth
273
- knowing:
274
-
275
- - **Scope: plain text content only.** `stream: true` combined with
276
- `tools` is rejected with a clear 400 rather than attempted -
277
- accumulating partial tool-call JSON arguments across chunks (possibly
278
- more than one call in flight at once) is a genuinely separate, harder
279
- problem. Send `stream: false` for tool-calling requests.
280
- - **A cache hit still streams.** Both the exact-match and semantic
281
- caches are checked before dispatching to a provider, same as the
282
- non-streaming path; a hit is replayed as SSE (one delta chunk with the
283
- whole cached answer, since it was never generated token-by-token to
284
- begin with) rather than forcing a streaming caller onto the slow path
285
- just because it asked for `stream: true`.
286
- - **Cost tracking on a streamed OpenAI response requires asking for
287
- it.** OpenAI only includes token-usage data on a stream at all when
288
- the request explicitly sets `stream_options: {include_usage: true}` -
289
- without it, a streamed response has NO usage data, which would
290
- silently make `cost_usd` wrong (stuck at 0) for every streamed OpenAI
291
- call. `providers/openai.js` sets this automatically; it's called out
292
- here because it's exactly the kind of easy-to-miss detail that quietly
293
- breaks the cost accounting this whole project exists for.
294
- - **A client disconnect aborts the upstream call.** If the caller goes
295
- away mid-stream, an `AbortController` cancels the in-flight provider
296
- request rather than continuing to pay for tokens nobody will read.
297
- - **A mid-stream provider error can't become an HTTP error status** -
298
- SSE headers are already sent by the time a provider error could occur.
299
- It arrives instead as an in-band `data: {"error":{"message":"..."}}`
300
- frame followed by `[DONE]`, which is the honest signal a streaming
301
- client can actually observe, rather than an unexplained connection
302
- close.
303
-
304
- ## Cost dashboard
305
-
306
- `GET /dashboard` is a real, working page - not a mockup - built as
307
- static HTML/CSS/vanilla JS with inline SVG charts, no external chart
308
- library or build step, consistent with this project's lightweight
309
- positioning. A few things worth knowing before relying on it:
310
-
311
- - **The internal key lives in the browser's localStorage.** The
312
- dashboard page asks for `MODEL_ROUTER_INTERNAL_KEY` once and stores it
313
- there for convenience, the same bearer-token model every other
314
- authenticated endpoint here already uses - there's no separate
315
- per-user account system, because this is a single-operator,
316
- self-hosted admin tool, not a multi-tenant product. If that key leaks
317
- from a shared/public machine's browser storage, treat it as
318
- compromised and rotate it.
319
- - **Auto-refresh polls; it doesn't push.** The "Auto-refresh" checkbox
320
- (on by default, preference kept in localStorage) re-fetches
321
- `GET /dashboard/data` every 30 seconds, paused while the tab isn't
322
- visible (`document.hidden`) and firing immediately when it becomes
323
- visible again. There's no server push/websocket here - a viewer
324
- watching in real time still only sees whatever changed in the last
325
- poll, not the instant it happened.
326
- - **"Requests by outcome" folds errors into whichever bucket they'd
327
- otherwise land in**, rather than giving errors their own stacked
328
- segment. A 4th visual series was worse than the alternative: the error
329
- count for each day is still fully available, both in that chart's
330
- hover tooltip ("N of the misses errored") and in its table view, plus
331
- precisely per-provider in the "Provider health" table and the
332
- dedicated "Error rate" KPI tile - nothing is hidden, it's just not a
333
- 4th color competing with the three that actually matter most.
334
- - **`GET /stats` and `GET /dashboard/data` intentionally use different
335
- windows.** `/stats` windows by the last N raw log *records* (a quick
336
- curl-able snapshot); `/dashboard/data` windows by *calendar days* (so
337
- its date-range picker means what it says). They will not show
338
- identical numbers for "the same" range, because they're not measuring
339
- the same thing - see the code comments in `server.js` if that's ever
340
- confusing.
341
- - The charts are original inline SVG (no canvas, no external library),
342
- built to the same practical bar - visible legends, hover tooltips
343
- reachable by pointer, a table-view twin for every chart so no value is
344
- color-only or hover-only, light/dark via `prefers-color-scheme`, a
345
- categorical palette checked for colorblind-safe separation.
346
-
347
- ## Where this leaves things (known gaps, stated plainly)
348
-
349
- - **Tool-call streaming isn't built.** Plain text streams end-to-end;
350
- `stream: true` combined with `tools` is rejected with a clear error
351
- rather than attempted (see "Streaming" above for why). Tool-calling
352
- requests need `stream: false` for now.
353
- - **Routing has three strategies, not a blended score - `ROUTER_STRATEGY`
354
- (default `cost`).** A weighted cost/latency formula would look more
355
- sophisticated but would really just be a made-up tradeoff this router
356
- has no basis for choosing on the deployer's behalf, so instead there
357
- are three simple, exactly-stated options:
358
- - `cost` (default, unchanged from before) - cheapest healthy candidate
359
- in the tier, full stop.
360
- - `latency` - fastest healthy candidate by recent average latency,
361
- full stop; cost only breaks a tie (most often when there's no
362
- latency history yet for either candidate).
363
- - `latency-guarded-cost` - cheapest healthy candidate, EXCLUDING any
364
- candidate whose recent average latency is more than
365
- `ROUTER_LATENCY_GUARD_MULTIPLIER` (default 3x) slower than the
366
- fastest known healthy candidate. A candidate with no latency history
367
- yet is never excluded by the guard. This is the one genuinely
368
- "latency-aware" option that still keeps cost as the primary signal -
369
- a guard rail against picking something dramatically slower to save a
370
- fraction of a cent, not a full re-ranking. With no latency data at
371
- all yet, it degrades to plain `cost`.
372
-
373
- `GET /health` reports the active strategy (`routing_strategy`); a
374
- decision's `reason.strategy` and `reason.latencyGuardExcludedACandidate`
375
- say which one ran and whether the guard actually did anything, same
376
- transparency style as the rest of the routing decision. Tier
377
- membership is still the deployer's quality-floor decision (see
378
- `router:frontier` below) - strategy only decides ranking *within*
379
- whatever tier was requested. If a deployment needs one specific model
380
- regardless of price or speed, name that model directly instead of a
381
- `router:` tier.
382
- - The `router:best` → `router:frontier` rename (still relevant context):
383
- a tier named "best" implied quality-aware selection that the code
384
- never actually did, so it silently picked whichever candidate was
385
- cheaper. The deployer decides which models belong in a tier (that's
386
- the quality floor); the router's job is ranking within it, by
387
- whichever strategy above is configured.
388
- - **Metrics storage is JSONL by default, Postgres if you set `DATABASE_URL`.**
389
- Unset, it's local, rotated-by-UTC-day JSONL files
390
- (`metrics-YYYY-MM-DD.jsonl`) - fine for a standalone deployment with
391
- no database of its own, but ephemeral on most hosts (a restart/
392
- redeploy wipes a container's own filesystem). Set `DATABASE_URL` to a
393
- real Postgres connection string and every metrics function
394
- transparently reads/writes there instead - MemoCode's own embedded
395
- deployment points this at its already-provisioned `memocode-db`
396
- rather than standing up a separate database just for cost history.
397
- Same public API either way (`record`/`readRecent`/`providerStats`/
398
- `rangeSummary`/`pruneOlderThan`); nothing outside `metrics.js` needs
399
- to know or care which backend is actually running. Retention is the
400
- same story regardless of backend: nothing is deleted automatically by
401
- default reasoning, but `pruneOlderThan(days)` is now actually wired up
402
- (a scheduled job in `server.js`, `METRICS_RETENTION_DAYS`, default 90
403
- - matching `/dashboard/data`'s own longest supported range) rather than
404
- existing but never being called.
405
- - **The semantic cache needs OPENAI_API_KEY regardless of which
406
- provider actually serves the chat request** - it's the only embedding
407
- backend implemented. An Anthropic-only deployment gets exact-match
408
- caching but not semantic caching unless it also configures an OpenAI
409
- key purely for embeddings.
410
- - **The dashboard's internal-key gate is convenience, not a real auth
411
- system** - see "Cost dashboard" above. Fine for a single self-hosted
412
- operator; not a substitute for real per-user accounts if this ever
413
- needs multiple people with different access levels. If you're
414
- embedding this inside an app that already has its own login, the
415
- clean pattern is to add a thin authenticated route on YOUR OWN backend
416
- that relays `GET /dashboard/data` to your signed-in users, gated by
417
- your own auth - so the router's shared secret never has to reach a
418
- browser at all. This page's own `GET /dashboard` is unchanged and
419
- still works standalone either way (useful for checking the router's
420
- health independent of anything wrapping it).
421
- - **Auto-refresh polls on a fixed interval (30s), not push-based.** The
422
- dashboard re-fetches `GET /dashboard/data` on a timer (visible in the
423
- "Auto-refresh" toggle and the "Updated Xs ago" text next to it),
424
- paused while the tab isn't visible and re-fetching immediately when it
425
- becomes visible again - there's no server-push/websocket, so a genuine
426
- real-time view isn't what this is. 30 seconds was picked as a
427
- reasonable balance for a cost dashboard, not tuned against any
428
- particular deployment's request volume.
1
+ # cachegate
2
+
3
+ [![Tests](https://github.com/iDebunk/cachegate/actions/workflows/test.yml/badge.svg)](https://github.com/iDebunk/cachegate/actions/workflows/test.yml)
4
+ [![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
5
+
6
+ A self-hostable, OpenAI-compatible proxy that routes LLM requests to the
7
+ cheapest currently-healthy provider, caches responses both exactly and
8
+ semantically, and tracks cost and latency per call.
9
+
10
+ ## Why this instead of LiteLLM / Portkey / OpenRouter?
11
+
12
+ Those are all excellent, and this doesn't try to out-feature them (140+
13
+ providers, a huge ecosystem, hosted enterprise plans). The niche this
14
+ fills instead:
15
+
16
+ - **Self-hosted first** — your prompts and provider keys never leave
17
+ your own infrastructure. No account, no telemetry, no hosted
18
+ dependency to go down.
19
+ - **Semantic cache included**, not just exact-match most lightweight
20
+ self-hosted options only hash-match identical requests. (LiteLLM does
21
+ now ship a more sophisticated vector-indexed semantic cache than this
22
+ project's brute-force cosine scan stated plainly, not glossed over;
23
+ see "Two kinds of cache hit" below for what this one actually does.)
24
+ - **Node.js/TypeScript-native**most comparable gateways are Python;
25
+ this fits directly into a JS/TS stack with no cross-language bridge.
26
+ - **Small and embeddable** — a handful of files, no framework beyond
27
+ Express, easy to read end to end and drop into an existing app's own
28
+ backend rather than standing up a separate service.
29
+ - **Honest numbers.** Caching alone typically saves 20-45% on LLM spend;
30
+ add routing and well-tuned traffic can reach 47-90%. Not the inflated
31
+ 86-95% figures some vendors quote real ranges, from real benchmarks.
32
+
33
+ ## What this is NOT
34
+
35
+ - **Not a hosted service.** There's no cloud offering, no login, no
36
+ billing, no multi-tenant key custody here — this is the engine you
37
+ run yourself. If you want that instead, that's a separate, closed
38
+ product built on top of this same engine not a fork of this one,
39
+ and not something this repository will ever grow into. This project
40
+ intentionally doesn't ship the pieces (billing, multi-tenant key
41
+ custody, a login system) that a competing hosted offering would need,
42
+ and isn't looking for PRs that add them (see `CONTRIBUTING.md`'s
43
+ scope note) not because the license forbids it (MIT permits
44
+ exactly that see `LICENSE`), but because it's not what this project
45
+ is for.
46
+ - **Not a 140-provider gateway.** Anthropic and OpenAI today (see
47
+ "Features" below for the honest current gap against a wider pitch).
48
+ - **Not a vector-indexed semantic cache** (yet) see "Two kinds of
49
+ cache hit" for the real, disclosed scale limit.
50
+
51
+ The last two are real gaps worth a PR. The first is a boundary, not a
52
+ gap — see `CONTRIBUTING.md` before opening one for it.
53
+
54
+ ## Run it
55
+
56
+ **Zero-clone** (once published to npm — see `OPEN_SOURCE_ROADMAP.md` step 17):
57
+ ```bash
58
+ npx cachegate
59
+ ```
60
+ Reads config from `.env` in the current directory by default, same as
61
+ every other option below. Pass `--env-path <file>` (e.g. `npx cachegate
62
+ --env-path ./router/.env`) to point it at a `.env` anywhere else instead
63
+ see "Wiring this into your app" below for why that's useful.
64
+
65
+ **Standalone** (this repo on its own):
66
+ ```bash
67
+ git clone <this-repo-url>
68
+ cd <repo-directory>
69
+ npm install
70
+ ```
71
+
72
+ **Embedded** (copied into an existing app's own backend, alongside its
73
+ other services): copy this directory into your project, then run the
74
+ same commands from inside it.
75
+
76
+ ```bash
77
+ npm install
78
+ ```
79
+
80
+ **Docker:**
81
+ ```bash
82
+ docker build -t cachegate .
83
+ docker run -p 4000:4000 --env-file .env cachegate
84
+ ```
85
+ Or skip building it yourself — pre-built images are published on both
86
+ GHCR and Docker Hub, kept in sync, either works the same:
87
+ ```bash
88
+ docker pull ghcr.io/idebunk/cachegate:latest
89
+ # or
90
+ docker pull docker.io/shipman/cachegate:latest
91
+ ```
92
+ The image runs as a non-root user, and its `HEALTHCHECK` calls the same
93
+ `GET /health` endpoint documented below — `docker ps` shows `healthy`/
94
+ `unhealthy` once the container's been up for a few seconds. **Redis is
95
+ not bundled in the image** — point `REDIS_URL` in your `.env` at an
96
+ existing Redis instance (a sibling container on the same Docker
97
+ network, or a managed one); without it the exact-match and semantic
98
+ caches are disabled cleanly (see "Features" below), not a startup
99
+ failure.
100
+
101
+ Create or edit your local `.env` file (do **not** overwrite an existing one):
102
+
103
+ ```text
104
+ PORT=4000
105
+ MODEL_ROUTER_INTERNAL_KEY=your-random-internal-key
106
+ ANTHROPIC_API_KEY=your-real-key-here
107
+ # Optional:
108
+ # OPENAI_API_KEY=your-openai-key-here
109
+ # REDIS_URL=redis://localhost:6379
110
+ ```
111
+
112
+ See `.env.example` for the full list of options (semantic cache
113
+ tuning, routing strategy, metrics storage, rate limits) — the model
114
+ itself is named per-request in the API call, not configured here.
115
+
116
+ `MODEL_ROUTER_INTERNAL_KEY` is required - the server refuses to start
117
+ without it, on purpose (see "Auth" below). For a throwaway local
118
+ instance only, you can skip it and set `ALLOW_INSECURE_LOCAL_DEV=true`
119
+ instead.
120
+
121
+ ```bash
122
+ npm start
123
+ ```
124
+
125
+ `.env` is gitignored. `.env.example` is only a reference template.
126
+
127
+ ## Wiring this into your app
128
+
129
+ **cachegate works with any language, not just Node/JS.** It's a plain
130
+ HTTP API (`POST /v1/chat/completions`) - your app calls it exactly the
131
+ way it already calls Anthropic or OpenAI directly, just pointed at a
132
+ different URL. Python, Go, Rust, Swift, curl, a mobile app - if it can
133
+ make an HTTP request, it can use cachegate. The only place Node.js is
134
+ ever required is the one folder cachegate's own process runs from -
135
+ never the app calling it.
136
+
137
+ **`MODEL_ROUTER_INTERNAL_KEY` is not a key this project ships with.**
138
+ It's a secret *you* generate (`openssl rand -hex 32`, a password
139
+ manager, anything random) and put in cachegate's own `.env`. Its only
140
+ job is stopping a stranger who reaches your running instance from
141
+ spending your real Anthropic/OpenAI budget for free. Your app then
142
+ sends that same value back as `Authorization: Bearer <your-key>` on
143
+ every request — think of it the same way you'd think of a database
144
+ password for a service you're standing up, not something built in.
145
+
146
+ **cachegate's `.env` is separate from your app's own `.env`.** It has
147
+ nothing to do with your app's database URL, its own auth secrets, or
148
+ anything else your app already configures — mixing them into one file
149
+ risks real collisions (if your app already uses `PORT` for its own
150
+ server, for instance). Where that `.env` actually needs to live depends
151
+ on how you're running it:
152
+
153
+ - **`npx cachegate` / a standalone clone**: by default, reads `.env`
154
+ from whatever directory you run the command *from* - not a fixed path
155
+ tied to the installed package. Run it from a dedicated folder made
156
+ for this purpose, rather than your app's own project root - otherwise
157
+ you'll either get a confusing "won't start" if there's no `.env`
158
+ there, or it'll silently pick up whatever unrelated `.env` happens to
159
+ already be in that folder. **Or skip the folder-matching entirely**:
160
+ `npx cachegate --env-path ./router/.env` reads from wherever you point
161
+ it, regardless of where you're standing - useful for a `package.json`
162
+ script (`"start:router": "npx cachegate --env-path ./router/.env"`),
163
+ CI, or running it from your project root without ever `cd`-ing into
164
+ the colocated subfolder below.
165
+ - **Docker**: `docker run --env-file .env ...` - the file lives on the
166
+ host wherever you run that command; it's injected only into
167
+ cachegate's own container, never shared with anything else.
168
+
169
+ **Two ways to place it - pick based on how you want to deploy, not
170
+ based on what language your app is written in:**
171
+
172
+ | | Where it lives | What your app needs |
173
+ |---|---|---|
174
+ | **Standalone** | Its own server, its own repo entirely | Nothing - any language, calls it over HTTP like any other service |
175
+ | **Colocated** | A subfolder inside your own repo (e.g. `your-app/router/`) | Nothing - only that one subfolder needs Node/npm installed |
176
+
177
+ Both are the exact same mechanism underneath - `npx cachegate` (or a
178
+ clone, or Docker) running as its own standalone process with its own
179
+ `.env`, listening on its own port. The only difference is where you put
180
+ the folder. Pick **standalone** if cachegate should be one shared
181
+ service serving multiple apps (or you'd rather manage it as its own
182
+ deployable thing). Pick **colocated** if you want everything - your app
183
+ plus its router - in one repo, one place to look, no second project to
184
+ maintain (this is exactly how this router lives inside MemoCode's own
185
+ monorepo today).
186
+
187
+ **Reaching it once it's running:**
188
+ - Same machine, calling app not containerized: `http://localhost:4000`.
189
+ - Both sides in Docker: put both containers on one Docker network (a
190
+ `docker-compose.yml` does this automatically) and reach it by service
191
+ name, e.g. `http://cachegate:4000` - Docker's own internal DNS handles
192
+ the rest.
193
+ - Genuinely separate hosts: put cachegate behind a reverse proxy
194
+ (Caddy/nginx/Traefik) for HTTPS rather than exposing its raw port to
195
+ the internet directly.
196
+
197
+ ## Usage
198
+
199
+ Direct dispatch - name a specific provider's model, same as calling that
200
+ provider yourself:
201
+
202
+ ```bash
203
+ curl http://localhost:4000/v1/chat/completions \
204
+ -H "Content-Type: application/json" \
205
+ -H "Authorization: Bearer your-random-internal-key" \
206
+ -d '{
207
+ "model": "claude-sonnet-4-5-20250929",
208
+ "max_tokens": 1024,
209
+ "messages": [{"role": "user", "content": "Say hello"}]
210
+ }'
211
+ ```
212
+
213
+ Routed dispatch - name a capability tier instead, and the router picks
214
+ the cheapest currently-healthy provider for it:
215
+
216
+ ```bash
217
+ curl http://localhost:4000/v1/chat/completions \
218
+ -H "Content-Type: application/json" \
219
+ -H "Authorization: Bearer your-random-internal-key" \
220
+ -d '{
221
+ "model": "router:fast-cheap",
222
+ "max_tokens": 1024,
223
+ "messages": [{"role": "user", "content": "Say hello"}]
224
+ }'
225
+ ```
226
+
227
+ `GET /health` lists the configured tiers and the active routing
228
+ strategy. Tiers are defined in `router.js` (`DEFAULT_TIERS`) and can be
229
+ overridden per deployment via the `ROUTER_TIERS_JSON` env var; the
230
+ strategy is `ROUTER_STRATEGY` (`cost` / `latency` / `latency-guarded-cost`,
231
+ default `cost`) - see "Where this leaves things" below for what each
232
+ one actually does.
233
+
234
+ Streamed dispatch - add `"stream": true` to either form above and get
235
+ back SSE chunks instead of one JSON body (see "Streaming" below for
236
+ scope):
237
+
238
+ ```bash
239
+ curl -N http://localhost:4000/v1/chat/completions \
240
+ -H "Content-Type: application/json" \
241
+ -H "Authorization: Bearer your-random-internal-key" \
242
+ -d '{
243
+ "model": "claude-sonnet-4-5-20250929",
244
+ "max_tokens": 1024,
245
+ "stream": true,
246
+ "messages": [{"role": "user", "content": "Say hello"}]
247
+ }'
248
+ ```
249
+
250
+ ## Auth
251
+
252
+ Every `/v1/*` and `/stats` request needs `Authorization: Bearer
253
+ <MODEL_ROUTER_INTERNAL_KEY>`. If the key isn't set, the server refuses
254
+ to start at all rather than falling open - an earlier version treated a
255
+ missing key as "no auth enforced," which is exactly the kind of thing
256
+ that turns into an unauthenticated proxy sitting in front of real
257
+ provider API keys the moment someone forgets to set it. Set
258
+ `ALLOW_INSECURE_LOCAL_DEV=true` to explicitly opt into running with no
259
+ auth, for local development only.
260
+
261
+ ## Features
262
+
263
+ - OpenAI-compatible `/v1/chat/completions` endpoint - direct dispatch to
264
+ a named provider model, or routed dispatch via a `router:` capability
265
+ tier (cheapest currently-healthy candidate, by estimated cost; see
266
+ `router.js`).
267
+ - **`stream: true` works** for plain text content, on both providers,
268
+ including replaying a cache hit (exact or semantic) as a stream so a
269
+ streaming caller still gets the caching benefit. See "Streaming"
270
+ below for the real scope boundary (tool-call streaming isn't
271
+ included) and the cost-tracking detail it depends on.
272
+ - Anthropic and OpenAI providers. (Not yet: Gemini, Groq, local models -
273
+ a real gap against the two-provider skeleton's original pitch.)
274
+ - Redis-backed exact-match response cache by content hash - the first,
275
+ free, zero-risk check on every request.
276
+ - A semantic cache on top of it, for near-duplicate prompts the exact
277
+ hash can't catch (a paraphrase, reordered context). Requires
278
+ `OPENAI_API_KEY` (the only embedding backend right now, regardless of
279
+ which provider actually answers the chat request) and Redis; disabled
280
+ cleanly if either is missing. Tool-calling requests are never
281
+ semantically cached (see `semanticCache.js`). `GET /health` reports
282
+ `semantic_cache_enabled`; `GET /stats` reports exact and semantic hit
283
+ rates **separately**, not blended - see "Two kinds of cache hit"
284
+ below for why that distinction matters.
285
+ - Per-request cost and latency tracking, persisted to a local JSONL log
286
+ (`metrics.js`) so routing decisions and `GET /stats` have real
287
+ history to work from, not just a number thrown away after each
288
+ response.
289
+ - Rate limiting on `/v1/*` (`RATE_LIMIT_MAX` requests per
290
+ `RATE_LIMIT_WINDOW_MS`, defaults 60/60s) - this proxy sits in front of
291
+ paid, metered keys, so an unbounded client has no ceiling otherwise.
292
+ - `GET /health` for monitoring (public, no auth) and `GET /stats` for a
293
+ quick record-count-windowed aggregate snapshot (auth required).
294
+ - **A cost dashboard** at `GET /dashboard` - a static page (no auth
295
+ itself; its own JS asks for the internal key and stores it in
296
+ localStorage, then calls the authenticated data endpoint below) with
297
+ KPI tiles, cost-over-time, requests-by-outcome, and cost-by-provider
298
+ charts, a 7/14/30-day range picker, a table-view twin for every chart,
299
+ and a toggleable 30-second auto-refresh (paused while the tab isn't
300
+ visible). Backed by `GET /dashboard/data` (auth required), which computes
301
+ everything from one calendar-windowed pass over the metrics log so the
302
+ tiles, charts, and provider table can never disagree with each other.
303
+ See "Two kinds of cache hit" below and "Cost dashboard" further down
304
+ for the real tradeoffs and limitations.
305
+ - Automated tests (`npm test`, Node's built-in test runner) covering
306
+ auth, request validation, routing decisions (including the unhealthy-
307
+ provider fallback), and the metrics store. They don't call a real
308
+ provider API - that needs live keys and real spend, out of scope for
309
+ this suite.
310
+
311
+ ## Two kinds of cache hit - why they're reported separately
312
+
313
+ An **exact** hit means this exact request (same model, same messages,
314
+ same params) was seen before - the cached response is guaranteed
315
+ correct for it. A **semantic** hit means a *different* request scored
316
+ above a similarity threshold against something cached before - the
317
+ router's best guess that they want the same answer, not proof they do.
318
+ Blending those into one "cache hit rate" number is exactly the failure
319
+ mode this project's own market research flagged in vendor marketing:
320
+ inflated headline hit-rate claims that don't hold up against real
321
+ production numbers. `GET /stats` reports `cache_hit_rate.exact`,
322
+ `.semantic`, and `.combined` as three separate numbers so nobody has to
323
+ take that on faith.
324
+
325
+ Practical tradeoff worth stating plainly: the semantic cache is not
326
+ free to run. Every request that misses the exact cache costs one
327
+ embedding call to check the semantic cache (`SEMANTIC_CACHE_THRESHOLD`,
328
+ default `0.93`, tunable) - whether or not it finds a match - plus
329
+ another embedding call to store the eventual answer. That's real cost
330
+ and latency on every miss, in exchange for a chance at skipping a much
331
+ larger completion call on a future near-duplicate. It's worth it when
332
+ near-duplicate traffic is common; it's pure overhead when it isn't. Set
333
+ `SEMANTIC_CACHE_ENABLED=false` to disable it outright while keeping the
334
+ exact-match cache and `OPENAI_API_KEY` for other things.
335
+
336
+ Storage is a plain Redis list per model, capped at
337
+ `SEMANTIC_CACHE_MAX_CANDIDATES` (default 200) - a lookup does a
338
+ brute-force cosine-similarity scan over that list in Node, not an
339
+ indexed vector search. No RediSearch or vector-search Redis module is
340
+ assumed (most self-hosted Redis, including Render's managed Redis,
341
+ doesn't have one). That's fine at single-instance, self-hosted volume;
342
+ it is not built to scale past that cap. See `semanticCache.js` for the
343
+ full reasoning.
344
+
345
+ ## Streaming
346
+
347
+ `stream: true` forwards a real, incremental, token-by-token response
348
+ from either provider, framed as OpenAI-compatible SSE chunks
349
+ (`data: {...}\n\n`, ending `data: [DONE]\n\n`). A few things worth
350
+ knowing:
351
+
352
+ - **Scope: plain text content only.** `stream: true` combined with
353
+ `tools` is rejected with a clear 400 rather than attempted -
354
+ accumulating partial tool-call JSON arguments across chunks (possibly
355
+ more than one call in flight at once) is a genuinely separate, harder
356
+ problem. Send `stream: false` for tool-calling requests.
357
+ - **A cache hit still streams.** Both the exact-match and semantic
358
+ caches are checked before dispatching to a provider, same as the
359
+ non-streaming path; a hit is replayed as SSE (one delta chunk with the
360
+ whole cached answer, since it was never generated token-by-token to
361
+ begin with) rather than forcing a streaming caller onto the slow path
362
+ just because it asked for `stream: true`.
363
+ - **Cost tracking on a streamed OpenAI response requires asking for
364
+ it.** OpenAI only includes token-usage data on a stream at all when
365
+ the request explicitly sets `stream_options: {include_usage: true}` -
366
+ without it, a streamed response has NO usage data, which would
367
+ silently make `cost_usd` wrong (stuck at 0) for every streamed OpenAI
368
+ call. `providers/openai.js` sets this automatically; it's called out
369
+ here because it's exactly the kind of easy-to-miss detail that quietly
370
+ breaks the cost accounting this whole project exists for.
371
+ - **A client disconnect aborts the upstream call.** If the caller goes
372
+ away mid-stream, an `AbortController` cancels the in-flight provider
373
+ request rather than continuing to pay for tokens nobody will read.
374
+ - **A mid-stream provider error can't become an HTTP error status** -
375
+ SSE headers are already sent by the time a provider error could occur.
376
+ It arrives instead as an in-band `data: {"error":{"message":"..."}}`
377
+ frame followed by `[DONE]`, which is the honest signal a streaming
378
+ client can actually observe, rather than an unexplained connection
379
+ close.
380
+
381
+ ## Cost dashboard
382
+
383
+ `GET /dashboard` is a real, working page - not a mockup - built as
384
+ static HTML/CSS/vanilla JS with inline SVG charts, no external chart
385
+ library or build step, consistent with this project's lightweight
386
+ positioning. A few things worth knowing before relying on it:
387
+
388
+ - **The internal key lives in the browser's localStorage.** The
389
+ dashboard page asks for `MODEL_ROUTER_INTERNAL_KEY` once and stores it
390
+ there for convenience, the same bearer-token model every other
391
+ authenticated endpoint here already uses - there's no separate
392
+ per-user account system, because this is a single-operator,
393
+ self-hosted admin tool, not a multi-tenant product. If that key leaks
394
+ from a shared/public machine's browser storage, treat it as
395
+ compromised and rotate it.
396
+ - **Auto-refresh polls; it doesn't push.** The "Auto-refresh" checkbox
397
+ (on by default, preference kept in localStorage) re-fetches
398
+ `GET /dashboard/data` every 30 seconds, paused while the tab isn't
399
+ visible (`document.hidden`) and firing immediately when it becomes
400
+ visible again. There's no server push/websocket here - a viewer
401
+ watching in real time still only sees whatever changed in the last
402
+ poll, not the instant it happened.
403
+ - **"Requests by outcome" folds errors into whichever bucket they'd
404
+ otherwise land in**, rather than giving errors their own stacked
405
+ segment. A 4th visual series was worse than the alternative: the error
406
+ count for each day is still fully available, both in that chart's
407
+ hover tooltip ("N of the misses errored") and in its table view, plus
408
+ precisely per-provider in the "Provider health" table and the
409
+ dedicated "Error rate" KPI tile - nothing is hidden, it's just not a
410
+ 4th color competing with the three that actually matter most.
411
+ - **`GET /stats` and `GET /dashboard/data` intentionally use different
412
+ windows.** `/stats` windows by the last N raw log *records* (a quick
413
+ curl-able snapshot); `/dashboard/data` windows by *calendar days* (so
414
+ its date-range picker means what it says). They will not show
415
+ identical numbers for "the same" range, because they're not measuring
416
+ the same thing - see the code comments in `server.js` if that's ever
417
+ confusing.
418
+ - The charts are original inline SVG (no canvas, no external library),
419
+ built to the same practical bar - visible legends, hover tooltips
420
+ reachable by pointer, a table-view twin for every chart so no value is
421
+ color-only or hover-only, light/dark via `prefers-color-scheme`, a
422
+ categorical palette checked for colorblind-safe separation.
423
+
424
+ ## Where this leaves things (known gaps, stated plainly)
425
+
426
+ - **Tool-call streaming isn't built.** Plain text streams end-to-end;
427
+ `stream: true` combined with `tools` is rejected with a clear error
428
+ rather than attempted (see "Streaming" above for why). Tool-calling
429
+ requests need `stream: false` for now.
430
+ - **Routing has three strategies, not a blended score - `ROUTER_STRATEGY`
431
+ (default `cost`).** A weighted cost/latency formula would look more
432
+ sophisticated but would really just be a made-up tradeoff this router
433
+ has no basis for choosing on the deployer's behalf, so instead there
434
+ are three simple, exactly-stated options:
435
+ - `cost` (default, unchanged from before) - cheapest healthy candidate
436
+ in the tier, full stop.
437
+ - `latency` - fastest healthy candidate by recent average latency,
438
+ full stop; cost only breaks a tie (most often when there's no
439
+ latency history yet for either candidate).
440
+ - `latency-guarded-cost` - cheapest healthy candidate, EXCLUDING any
441
+ candidate whose recent average latency is more than
442
+ `ROUTER_LATENCY_GUARD_MULTIPLIER` (default 3x) slower than the
443
+ fastest known healthy candidate. A candidate with no latency history
444
+ yet is never excluded by the guard. This is the one genuinely
445
+ "latency-aware" option that still keeps cost as the primary signal -
446
+ a guard rail against picking something dramatically slower to save a
447
+ fraction of a cent, not a full re-ranking. With no latency data at
448
+ all yet, it degrades to plain `cost`.
449
+
450
+ `GET /health` reports the active strategy (`routing_strategy`); a
451
+ decision's `reason.strategy` and `reason.latencyGuardExcludedACandidate`
452
+ say which one ran and whether the guard actually did anything, same
453
+ transparency style as the rest of the routing decision. Tier
454
+ membership is still the deployer's quality-floor decision (see
455
+ `router:frontier` below) - strategy only decides ranking *within*
456
+ whatever tier was requested. If a deployment needs one specific model
457
+ regardless of price or speed, name that model directly instead of a
458
+ `router:` tier.
459
+ - The `router:best` → `router:frontier` rename (still relevant context):
460
+ a tier named "best" implied quality-aware selection that the code
461
+ never actually did, so it silently picked whichever candidate was
462
+ cheaper. The deployer decides which models belong in a tier (that's
463
+ the quality floor); the router's job is ranking within it, by
464
+ whichever strategy above is configured.
465
+ - **Metrics storage is JSONL by default, Postgres if you set `DATABASE_URL`.**
466
+ Unset, it's local, rotated-by-UTC-day JSONL files
467
+ (`metrics-YYYY-MM-DD.jsonl`) - fine for a standalone deployment with
468
+ no database of its own, but ephemeral on most hosts (a restart/
469
+ redeploy wipes a container's own filesystem). Set `DATABASE_URL` to a
470
+ real Postgres connection string and every metrics function
471
+ transparently reads/writes there instead - MemoCode's own embedded
472
+ deployment points this at its already-provisioned `memocode-db`
473
+ rather than standing up a separate database just for cost history.
474
+ Same public API either way (`record`/`readRecent`/`providerStats`/
475
+ `rangeSummary`/`pruneOlderThan`); nothing outside `metrics.js` needs
476
+ to know or care which backend is actually running. Retention is the
477
+ same story regardless of backend: nothing is deleted automatically by
478
+ default reasoning, but `pruneOlderThan(days)` is now actually wired up
479
+ (a scheduled job in `server.js`, `METRICS_RETENTION_DAYS`, default 90
480
+ - matching `/dashboard/data`'s own longest supported range) rather than
481
+ existing but never being called.
482
+ - **The semantic cache needs OPENAI_API_KEY regardless of which
483
+ provider actually serves the chat request** - it's the only embedding
484
+ backend implemented. An Anthropic-only deployment gets exact-match
485
+ caching but not semantic caching unless it also configures an OpenAI
486
+ key purely for embeddings.
487
+ - **The dashboard's internal-key gate is convenience, not a real auth
488
+ system** - see "Cost dashboard" above. Fine for a single self-hosted
489
+ operator; not a substitute for real per-user accounts if this ever
490
+ needs multiple people with different access levels. If you're
491
+ embedding this inside an app that already has its own login, the
492
+ clean pattern is to add a thin authenticated route on YOUR OWN backend
493
+ that relays `GET /dashboard/data` to your signed-in users, gated by
494
+ your own auth - so the router's shared secret never has to reach a
495
+ browser at all. This page's own `GET /dashboard` is unchanged and
496
+ still works standalone either way (useful for checking the router's
497
+ health independent of anything wrapping it).
498
+ - **Auto-refresh polls on a fixed interval (30s), not push-based.** The
499
+ dashboard re-fetches `GET /dashboard/data` on a timer (visible in the
500
+ "Auto-refresh" toggle and the "Updated Xs ago" text next to it),
501
+ paused while the tab isn't visible and re-fetching immediately when it
502
+ becomes visible again - there's no server-push/websocket, so a genuine
503
+ real-time view isn't what this is. 30 seconds was picked as a
504
+ reasonable balance for a cost dashboard, not tuned against any
505
+ particular deployment's request volume.