cachegate 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +11 -0
- package/.env.example +112 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +33 -0
- package/.github/ISSUE_TEMPLATE/config.yml +8 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +29 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +25 -0
- package/.github/workflows/test.yml +63 -0
- package/CODE_OF_CONDUCT.md +66 -0
- package/CONTRIBUTING.md +94 -0
- package/Dockerfile +16 -0
- package/LICENSE +21 -0
- package/OPEN_SOURCE_ROADMAP.md +554 -0
- package/README.md +428 -0
- package/ROADMAP.md +281 -0
- package/SECURITY.md +39 -0
- package/cache.js +51 -0
- package/embeddings.js +32 -0
- package/failover.js +76 -0
- package/metrics.js +556 -0
- package/package.json +26 -0
- package/providers/anthropic.js +122 -0
- package/providers/openai.js +114 -0
- package/public/dashboard.html +1119 -0
- package/redisClient.js +45 -0
- package/router.js +218 -0
- package/semanticCache.js +154 -0
- package/server.js +668 -0
- package/streaming.js +77 -0
- package/sync-oss-release.sh +160 -0
- package/test/auth-config.test.js +27 -0
- package/test/cache.test.js +33 -0
- package/test/embeddings.test.js +24 -0
- package/test/failover.test.js +99 -0
- package/test/metrics-postgres.test.js +183 -0
- package/test/metrics.test.js +282 -0
- package/test/router.test.js +195 -0
- package/test/semanticCache.test.js +167 -0
- package/test/server.test.js +357 -0
- package/test/streaming.test.js +248 -0
package/README.md
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
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
|
+
[](https://github.com/OWNER/cachegate/actions/workflows/test.yml)
|
|
6
|
+
[](./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 here — this 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.
|