cachegate 1.3.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +83 -4
- package/README.md +154 -15
- package/cache.js +133 -9
- package/cascade.js +206 -0
- package/coalescing.js +65 -0
- package/embeddings.js +78 -16
- package/guardrails.js +80 -0
- package/metrics.js +89 -5
- package/package.json +12 -2
- package/pii.js +128 -0
- package/providers/deepseek.js +194 -0
- package/providers/index.js +62 -0
- package/providers/openai.js +10 -2
- package/providers/openrouter.js +233 -0
- package/public/dashboard.html +1 -0
- package/router.js +33 -4
- package/semanticCache.js +115 -3
- package/server.js +494 -118
- package/tracing.js +97 -0
package/.env.example
CHANGED
|
@@ -16,12 +16,37 @@ MODEL_ROUTER_INTERNAL_KEY=your-random-internal-key
|
|
|
16
16
|
# from outside your own machine.
|
|
17
17
|
# ALLOW_INSECURE_LOCAL_DEV=true
|
|
18
18
|
|
|
19
|
-
# Required (at least one provider key -
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
19
|
+
# Required (at least one provider key - any of the four). Which provider serves
|
|
20
|
+
# a request is decided by the model name in the API call's own "model" field
|
|
21
|
+
# (see README's "Usage" section), not configured here - there is no
|
|
22
|
+
# ANTHROPIC_MODEL/OPENAI_MODEL env var to set:
|
|
23
|
+
# claude-* -> Anthropic
|
|
24
|
+
# gpt-*/o1*/o3* -> OpenAI
|
|
25
|
+
# deepseek-* -> DeepSeek (deepseek-flash, deepseek-v4-pro)
|
|
26
|
+
# vendor/model -> OpenRouter (e.g. meta/llama-3-70b, deepseek/deepseek-chat)
|
|
23
27
|
ANTHROPIC_API_KEY=sk-ant-api03-...
|
|
24
28
|
|
|
29
|
+
# Optional: DeepSeek. Two things worth knowing before you set this:
|
|
30
|
+
# * pricing is TIME-AWARE here (DeepSeek bills peak and off-peak, off-peak
|
|
31
|
+
# exactly half), so cost_usd - and cost-based routing - follows the current
|
|
32
|
+
# billing window rather than one flat rate.
|
|
33
|
+
# * DeepSeek reports its own disk-cache tiers (prompt_cache_hit_tokens /
|
|
34
|
+
# prompt_cache_miss_tokens). Cached input is ~50x cheaper than a miss, and
|
|
35
|
+
# those fields are mapped through, so a cached request shows its real cost
|
|
36
|
+
# instead of being priced as if nothing was cached.
|
|
37
|
+
# DEEPSEEK_API_KEY=sk-...
|
|
38
|
+
# DEEPSEEK_BASE_URL=https://api.deepseek.com # override for a proxy/gateway
|
|
39
|
+
|
|
40
|
+
# Optional: OpenRouter - one key, many vendors, addressed as vendor/model.
|
|
41
|
+
# Pricing is fetched from OpenRouter's own /models endpoint and cached, NOT
|
|
42
|
+
# hardcoded: their catalog is large and changes without a release of this
|
|
43
|
+
# project. When a model's price is unknown, cost_usd is null (never 0 - a zero
|
|
44
|
+
# would read as "free" to cost-based routing and win every comparison).
|
|
45
|
+
# OPENROUTER_API_KEY=sk-or-...
|
|
46
|
+
# OPENROUTER_SITE_URL=https://your-app.example.com # optional attribution headers:
|
|
47
|
+
# OPENROUTER_SITE_NAME=Your App # sent only if you set them
|
|
48
|
+
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 # override for a proxy/gateway
|
|
49
|
+
|
|
25
50
|
# Also required for the semantic cache's embeddings, regardless of
|
|
26
51
|
# which provider actually serves chat requests - it's the only
|
|
27
52
|
# embedding backend implemented (see semanticCache.js / README).
|
|
@@ -125,3 +150,57 @@ ANTHROPIC_API_KEY=sk-ant-api03-...
|
|
|
125
150
|
# legitimate reason for a much larger payload. Raise only if you have
|
|
126
151
|
# a specific reason to expect longer request bodies.
|
|
127
152
|
# JSON_BODY_LIMIT=2mb
|
|
153
|
+
|
|
154
|
+
# Optional: guardrails (roadmap step 25). Both default OFF, run
|
|
155
|
+
# pre-dispatch (before either cache lookup, so cached content is
|
|
156
|
+
# already redacted/policy-checked), zero behavior change unless you opt in.
|
|
157
|
+
|
|
158
|
+
# PII redaction (pii.js): pattern-based (emails, phone numbers, SSNs,
|
|
159
|
+
# credit card numbers, common API-key/secret shapes), synchronous and
|
|
160
|
+
# local - no network call of its own. redact() never surfaces the
|
|
161
|
+
# actual matched value anywhere, even internally - only a
|
|
162
|
+
# {type, count} summary.
|
|
163
|
+
# GUARDRAILS_PII_REDACTION=true
|
|
164
|
+
|
|
165
|
+
# Prompt-injection detection (guardrails.js): heuristic patterns
|
|
166
|
+
# (instruction-override, system-prompt-leak, role-play jailbreak,
|
|
167
|
+
# "developer mode", DAN, "no restrictions" framing). GUARDRAILS_ENABLED
|
|
168
|
+
# turns detection on at all; GUARDRAILS_INJECTION_ACTION picks what
|
|
169
|
+
# happens on a hit - default `flag` (logged, request proceeds, since
|
|
170
|
+
# heuristics false-positive) or `block` (403, request rejected).
|
|
171
|
+
# GUARDRAILS_ENABLED=true
|
|
172
|
+
# GUARDRAILS_INJECTION_ACTION=flag # flag (default) | block | log - flag and log currently behave identically (console.warn + pass through); block is the only one that changes the response (403)
|
|
173
|
+
|
|
174
|
+
# Optional: cascade routing (roadmap step 34). Default OFF - this is
|
|
175
|
+
# genuinely more speculative than health scoring (an LLM grading another
|
|
176
|
+
# LLM's confidence is inherently fuzzy), so it ships inert and only
|
|
177
|
+
# activates when explicitly opted in. Applies to "router:" virtual models
|
|
178
|
+
# only: dispatch to the top-ranked candidate, estimate its confidence, and
|
|
179
|
+
# escalate to the next candidate when below the threshold instead of
|
|
180
|
+
# accepting a cheap-but-unconfident answer.
|
|
181
|
+
# CASCADE_ENABLED=true
|
|
182
|
+
|
|
183
|
+
# Optional: the confidence floor (0-1) below which a successful response is
|
|
184
|
+
# escalated rather than accepted. Only matters when CASCADE_ENABLED=true.
|
|
185
|
+
# CASCADE_CONFIDENCE_THRESHOLD=0.5
|
|
186
|
+
|
|
187
|
+
# Optional: the small/cheap grader model used to estimate confidence for
|
|
188
|
+
# Anthropic candidates (Anthropic has no native logprobs; OpenAI confidence
|
|
189
|
+
# comes from logprobs automatically when cascade is on). Unset (default)
|
|
190
|
+
# means Anthropic candidates are never escalated - cascade fails open to
|
|
191
|
+
# "accept" when there's no confidence signal. Set it to a full model name
|
|
192
|
+
# the router has a key for (e.g. gpt-4o-mini or claude-haiku-4-5-20251001);
|
|
193
|
+
# the provider is inferred from the name.
|
|
194
|
+
# CASCADE_GRADER_MODEL=gpt-4o-mini
|
|
195
|
+
|
|
196
|
+
# Optional: OpenTelemetry export (roadmap step 36.2). Default OFF - ships
|
|
197
|
+
# fully inert; the SDK is never even loaded unless you opt in. Every request
|
|
198
|
+
# gets a root span (trace_id carried as a span attribute so it can be joined
|
|
199
|
+
# to the same request's metrics rows) plus child spans for cache lookup,
|
|
200
|
+
# coalescing wait, and each provider dispatch attempt.
|
|
201
|
+
# OTEL_ENABLED=true
|
|
202
|
+
|
|
203
|
+
# Required for the SDK to actually initialize when OTEL_ENABLED=true (the
|
|
204
|
+
# OTLP HTTP collector endpoint, e.g. http://localhost:4318/v1/traces). Unset
|
|
205
|
+
# = the SDK never initializes at all, not "initializes and exports nowhere."
|
|
206
|
+
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces
|
package/README.md
CHANGED
|
@@ -7,6 +7,12 @@ A self-hostable, OpenAI-compatible proxy that routes LLM requests to the
|
|
|
7
7
|
cheapest currently-healthy provider, caches responses both exactly and
|
|
8
8
|
semantically, and tracks cost and latency per call.
|
|
9
9
|
|
|
10
|
+
Would rather not run it yourself? [Cachegate Cloud](https://cachegate.memocodesystem.com)
|
|
11
|
+
is a hosted version of this same engine — BYOK, so you're never paying
|
|
12
|
+
us for tokens, only for the caching/routing layer on top. Everything
|
|
13
|
+
below in this README describes the engine itself; both this repo and
|
|
14
|
+
the hosted product share it.
|
|
15
|
+
|
|
10
16
|
## Why this instead of LiteLLM / Portkey / OpenRouter?
|
|
11
17
|
|
|
12
18
|
Those are all excellent, and this doesn't try to out-feature them (140+
|
|
@@ -35,17 +41,19 @@ fills instead:
|
|
|
35
41
|
|
|
36
42
|
- **Not a hosted service.** There's no cloud offering, no login, no
|
|
37
43
|
billing, no multi-tenant key custody here — this is the engine you
|
|
38
|
-
run yourself. If you want that instead,
|
|
39
|
-
product built on top of this same engine — not
|
|
40
|
-
and not something this repository will ever grow
|
|
41
|
-
intentionally doesn't ship the pieces (billing,
|
|
42
|
-
custody, a login system) that a competing hosted
|
|
43
|
-
and isn't looking for PRs that add them (see
|
|
44
|
-
scope note) — not because the license forbids it
|
|
45
|
-
exactly that — see `LICENSE`), but because it's not what
|
|
46
|
-
is for.
|
|
47
|
-
- **Not a 140-provider gateway.**
|
|
48
|
-
|
|
44
|
+
run yourself. If you want that instead, [Cachegate Cloud](https://cachegate.memocodesystem.com)
|
|
45
|
+
is a separate, closed product built on top of this same engine — not
|
|
46
|
+
a fork of this one, and not something this repository will ever grow
|
|
47
|
+
into. This project intentionally doesn't ship the pieces (billing,
|
|
48
|
+
multi-tenant key custody, a login system) that a competing hosted
|
|
49
|
+
offering would need, and isn't looking for PRs that add them (see
|
|
50
|
+
`CONTRIBUTING.md`'s scope note) — not because the license forbids it
|
|
51
|
+
(MIT permits exactly that — see `LICENSE`), but because it's not what
|
|
52
|
+
this project is for.
|
|
53
|
+
- **Not a 140-provider gateway.** Four providers today — Anthropic, OpenAI,
|
|
54
|
+
DeepSeek and OpenRouter (the last one fronting many vendors behind a single
|
|
55
|
+
key). No Gemini, no Groq, no local models yet; see "Features" below for the
|
|
56
|
+
honest current gap against a wider pitch.
|
|
49
57
|
- **Not a vector-indexed semantic cache** (yet) — see "Two kinds of
|
|
50
58
|
cache hit" for the real, disclosed scale limit.
|
|
51
59
|
|
|
@@ -105,8 +113,10 @@ Create or edit your local `.env` file (do **not** overwrite an existing one):
|
|
|
105
113
|
PORT=4000
|
|
106
114
|
MODEL_ROUTER_INTERNAL_KEY=your-random-internal-key
|
|
107
115
|
ANTHROPIC_API_KEY=your-real-key-here
|
|
108
|
-
# Optional:
|
|
116
|
+
# Optional - any one of these unlocks that provider's models:
|
|
109
117
|
# OPENAI_API_KEY=your-openai-key-here
|
|
118
|
+
# DEEPSEEK_API_KEY=your-deepseek-key-here # models: deepseek-flash, deepseek-v4-pro
|
|
119
|
+
# OPENROUTER_API_KEY=your-openrouter-key-here # models: any vendor/model id, e.g. meta/llama-3-70b
|
|
110
120
|
# REDIS_URL=redis://localhost:6379
|
|
111
121
|
```
|
|
112
122
|
|
|
@@ -198,7 +208,9 @@ monorepo today).
|
|
|
198
208
|
## Usage
|
|
199
209
|
|
|
200
210
|
Direct dispatch - name a specific provider's model, same as calling that
|
|
201
|
-
provider yourself:
|
|
211
|
+
provider yourself. The provider is chosen from the model name: `claude-*` ->
|
|
212
|
+
Anthropic, `gpt-*`/`o1*`/`o3*` -> OpenAI, `deepseek-*` -> DeepSeek, and
|
|
213
|
+
`vendor/model` -> OpenRouter.
|
|
202
214
|
|
|
203
215
|
```bash
|
|
204
216
|
curl http://localhost:4000/v1/chat/completions \
|
|
@@ -211,6 +223,13 @@ curl http://localhost:4000/v1/chat/completions \
|
|
|
211
223
|
}'
|
|
212
224
|
```
|
|
213
225
|
|
|
226
|
+
Same call against DeepSeek or OpenRouter - only the `model` changes:
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
-d '{"model": "deepseek-flash", "messages": [{"role": "user", "content": "Say hello"}]}'
|
|
230
|
+
-d '{"model": "meta/llama-3-70b", "messages": [{"role": "user", "content": "Say hello"}]}'
|
|
231
|
+
```
|
|
232
|
+
|
|
214
233
|
Routed dispatch - name a capability tier instead, and the router picks
|
|
215
234
|
the cheapest currently-healthy provider for it:
|
|
216
235
|
|
|
@@ -278,8 +297,18 @@ auth, for local development only.
|
|
|
278
297
|
streaming caller still gets the caching benefit. See "Streaming"
|
|
279
298
|
below for the real scope boundary (tool-call streaming isn't
|
|
280
299
|
included) and the cost-tracking detail it depends on.
|
|
281
|
-
- Anthropic and
|
|
282
|
-
a real gap against
|
|
300
|
+
- Four providers: Anthropic, OpenAI, DeepSeek and OpenRouter. (Not yet:
|
|
301
|
+
Gemini, Groq, local models - still a real gap against a wide-gateway pitch,
|
|
302
|
+
just a smaller one.) Adding another is deliberately cheap now: one module
|
|
303
|
+
under `providers/` plus a line in `providers/index.js` - every dispatch
|
|
304
|
+
path, the model-name detection and the "which key is missing" error all read
|
|
305
|
+
from that registry rather than hardcoding a pair.
|
|
306
|
+
- **DeepSeek pricing is time-aware.** DeepSeek bills input in two tiers (cache
|
|
307
|
+
hit vs miss) and every rate has a peak and an off-peak value, so its cost
|
|
308
|
+
estimates - and therefore cost-based routing - reflect the current billing
|
|
309
|
+
window instead of a single flat rate. Opting into DeepSeek is also the one
|
|
310
|
+
place where prompt caching is billed this aggressively, so `cost_usd` on
|
|
311
|
+
those requests can be dramatically lower than the token count suggests.
|
|
283
312
|
- Redis-backed exact-match response cache by content hash - the first,
|
|
284
313
|
free, zero-risk check on every request.
|
|
285
314
|
- A semantic cache on top of it, for near-duplicate prompts the exact
|
|
@@ -318,6 +347,23 @@ auth, for local development only.
|
|
|
318
347
|
provider fallback), and the metrics store. They don't call a real
|
|
319
348
|
provider API - that needs live keys and real spend, out of scope for
|
|
320
349
|
this suite.
|
|
350
|
+
- **Guardrails - PII redaction and prompt-injection detection**, both
|
|
351
|
+
off by default (opt-in, zero behavior change until you set an env
|
|
352
|
+
var). Runs pre-dispatch, before either cache lookup, so redacted
|
|
353
|
+
content is what gets cached and a blocked request never reaches a
|
|
354
|
+
provider:
|
|
355
|
+
- `GUARDRAILS_PII_REDACTION=true` - pattern-based detection +
|
|
356
|
+
redaction for emails, phone numbers, SSNs, credit card numbers
|
|
357
|
+
(shape + Luhn checksum), and common vendor API-key/secret shapes
|
|
358
|
+
(`pii.js`). Never surfaces the actual matched value anywhere, even
|
|
359
|
+
internally - only a `{type, count}` summary.
|
|
360
|
+
- `GUARDRAILS_ENABLED=true` - heuristic prompt-injection detection
|
|
361
|
+
(instruction-override, system-prompt-leak, role-play jailbreak,
|
|
362
|
+
"developer mode", DAN, "no restrictions" framing - `guardrails.js`).
|
|
363
|
+
Default action on a hit is `flag` (logged, request still proceeds) -
|
|
364
|
+
heuristics false-positive, so auto-blocking real traffic isn't the
|
|
365
|
+
default. Set `GUARDRAILS_INJECTION_ACTION=block` to reject a
|
|
366
|
+
detected attempt with `403` instead.
|
|
321
367
|
|
|
322
368
|
## Two kinds of cache hit - why they're reported separately
|
|
323
369
|
|
|
@@ -360,6 +406,99 @@ doesn't have one). That's fine at single-instance, self-hosted volume;
|
|
|
360
406
|
it is not built to scale past that cap. See `semanticCache.js` for the
|
|
361
407
|
full reasoning.
|
|
362
408
|
|
|
409
|
+
## Provider prompt caching + Cachegate's own cache - compounding, not fighting
|
|
410
|
+
|
|
411
|
+
Anthropic and OpenAI both have their own prompt-caching feature,
|
|
412
|
+
separate from anything in this project - a KV-cache the provider
|
|
413
|
+
itself keeps for a repeated, unchanging prefix (a long system prompt,
|
|
414
|
+
a set of few-shot examples, a big shared document) so it doesn't get
|
|
415
|
+
reprocessed on every call. It's easy to assume that overlaps with
|
|
416
|
+
Cachegate's own exact/semantic cache and picking one means giving up
|
|
417
|
+
the other. It doesn't - they solve different problems, and used
|
|
418
|
+
together they compound:
|
|
419
|
+
|
|
420
|
+
- **Cachegate's cache is checked FIRST, before any provider is ever
|
|
421
|
+
called.** An exact or semantic hit costs `$0` and involves the
|
|
422
|
+
provider not at all - strictly better than even a heavily-discounted
|
|
423
|
+
cached-prefix rate, because there's no completion call at all.
|
|
424
|
+
- **Provider-level prompt caching only ever matters on a genuine
|
|
425
|
+
Cachegate miss** - a request different enough (in its varying tail)
|
|
426
|
+
that it doesn't match anything cached, but sharing a long, unchanging
|
|
427
|
+
prefix (system prompt, few-shot examples) with other misses that came
|
|
428
|
+
before it. That's the case Cachegate's own cache structurally can't
|
|
429
|
+
help with - the *tail* differs, so the request as a whole is a miss -
|
|
430
|
+
but the provider's own cache can still skip reprocessing the shared
|
|
431
|
+
*prefix*, cutting cost and latency on every one of those misses.
|
|
432
|
+
|
|
433
|
+
**This already works transparently - no cachegate code change
|
|
434
|
+
needed.** `providers/anthropic.js` and `providers/openai.js` both
|
|
435
|
+
forward your `messages`/`system`/`tools` fields through to the
|
|
436
|
+
provider's SDK as-is; neither reshapes message content or strips
|
|
437
|
+
unrecognized properties from it. Concretely:
|
|
438
|
+
|
|
439
|
+
- **Anthropic**: mark the unchanging part with
|
|
440
|
+
`cache_control: {"type": "ephemeral"}`, same as you would calling
|
|
441
|
+
Anthropic directly - on the system prompt, on a tool definition, or
|
|
442
|
+
on a specific content block within `messages`. Whatever object you
|
|
443
|
+
put there reaches `client.messages.create()` unchanged.
|
|
444
|
+
|
|
445
|
+
```bash
|
|
446
|
+
curl http://localhost:4000/v1/chat/completions \
|
|
447
|
+
-H "Content-Type: application/json" \
|
|
448
|
+
-H "Authorization: Bearer your-random-internal-key" \
|
|
449
|
+
-d '{
|
|
450
|
+
"model": "claude-sonnet-4-5-20250929",
|
|
451
|
+
"max_tokens": 1024,
|
|
452
|
+
"messages": [
|
|
453
|
+
{
|
|
454
|
+
"role": "system",
|
|
455
|
+
"content": [
|
|
456
|
+
{
|
|
457
|
+
"type": "text",
|
|
458
|
+
"text": "<...your long, unchanging system prompt / few-shot examples...>",
|
|
459
|
+
"cache_control": {"type": "ephemeral"}
|
|
460
|
+
}
|
|
461
|
+
]
|
|
462
|
+
},
|
|
463
|
+
{"role": "user", "content": "This part changes on every call."}
|
|
464
|
+
]
|
|
465
|
+
}'
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
Anthropic's own docs are the source of truth for the minimum
|
|
469
|
+
cacheable prompt length (model-dependent) and the 5-minute default
|
|
470
|
+
TTL - this project doesn't set or override either.
|
|
471
|
+
|
|
472
|
+
- **OpenAI**: fully automatic, no request changes at all. Once a
|
|
473
|
+
prompt's shared prefix is long enough (OpenAI's own current
|
|
474
|
+
threshold; see their docs, not repeated here since it's a number
|
|
475
|
+
they control and could change), OpenAI caches it on their side by
|
|
476
|
+
default - the exact same `messages` array you're already sending
|
|
477
|
+
through `providers/openai.js` unmodified is what makes this work, or
|
|
478
|
+
not, entirely on their end.
|
|
479
|
+
|
|
480
|
+
**One thing worth watching, specific to Cachegate's own exact cache:**
|
|
481
|
+
`cache.buildCacheKey()` hashes your `messages` (and `tools`) as given -
|
|
482
|
+
a `cache_control` block is just another property on a content object,
|
|
483
|
+
and it participates in that hash like anything else. Two requests that
|
|
484
|
+
are otherwise identical but differ only in whether `cache_control` is
|
|
485
|
+
present will land in **different** Cachegate exact-cache entries -
|
|
486
|
+
Cachegate doesn't know the extra field is caching metadata, it's just
|
|
487
|
+
part of the request shape. Not a bug, just a consequence of exact
|
|
488
|
+
meaning exact: keep `cache_control` usage *consistent* across calls
|
|
489
|
+
for the same logical prompt (always include it, or never) rather than
|
|
490
|
+
sometimes adding it, or you'll needlessly fragment Cachegate's own
|
|
491
|
+
cache into two variants of what should be one entry.
|
|
492
|
+
|
|
493
|
+
**If `GUARDRAILS_PII_REDACTION` is on** (see "Features" above): PII
|
|
494
|
+
redaction only ever rewrites a content block's `text` field in place -
|
|
495
|
+
`cache_control` and every other property on that block pass through
|
|
496
|
+
untouched. Redaction changing the *text* of a normally-stable shared
|
|
497
|
+
prefix would still be an unusual thing to have happen (a system prompt
|
|
498
|
+
containing PII isn't a common shape), but if it ever does, the
|
|
499
|
+
redacted version is what both Cachegate's cache key and the provider's
|
|
500
|
+
own cache lookup see - consistently, on every call, not just some.
|
|
501
|
+
|
|
363
502
|
## Streaming
|
|
364
503
|
|
|
365
504
|
`stream: true` forwards a real, incremental, token-by-token response
|
package/cache.js
CHANGED
|
@@ -22,21 +22,141 @@ const redis = require('./redisClient');
|
|
|
22
22
|
// isolated even if the caller's own scope-naming convention were ever
|
|
23
23
|
// guessed or leaked; a compromised/guessed prefix alone can't be walked
|
|
24
24
|
// into another scope's cached content.
|
|
25
|
+
|
|
26
|
+
// Prompt canonicalization (Phase 2, step 21) - normalizes ONLY what gets
|
|
27
|
+
// HASHED, never what is sent to the provider (buildCacheKey receives the
|
|
28
|
+
// payload, but the provider call in server.js uses payload.messages raw).
|
|
29
|
+
// Two prompts that differ only by whitespace/case-of-structure/literal
|
|
30
|
+
// values must hash to the SAME key. Conservative by design: a false-
|
|
31
|
+
// positive collapse (two prompts that actually want different answers
|
|
32
|
+
// sharing one key) is worse than a miss, so this folds only surface-level
|
|
33
|
+
// variation, never meaning.
|
|
34
|
+
function normalizeMessages(messages) {
|
|
35
|
+
if (!Array.isArray(messages)) return messages;
|
|
36
|
+
return messages.map((m) => {
|
|
37
|
+
if (!m || typeof m !== 'object') return m;
|
|
38
|
+
// Canonical field order (role, content first) so {role,content} and
|
|
39
|
+
// {content,role} hash identically; any other fields (name,
|
|
40
|
+
// tool_call_id, ...) keep their original relative order after that.
|
|
41
|
+
const out = { role: m.role, content: normalizeContent(m.content) };
|
|
42
|
+
for (const key of Object.keys(m)) {
|
|
43
|
+
if (key !== 'role' && key !== 'content') out[key] = m[key];
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeContent(content) {
|
|
50
|
+
if (typeof content === 'string') return normalizeText(content);
|
|
51
|
+
if (Array.isArray(content)) return content.map(normalizeContent);
|
|
52
|
+
return content;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Conservative canonical form of one text block: NFC unicode, structural
|
|
56
|
+
// The literal-slotting steps, defined ONCE: normalizeText applies them, and slottingFlags() reports
|
|
57
|
+
// which ones actually fire. Two definitions would be the same mistake R2 fixed one file over - a rule
|
|
58
|
+
// enforced on one path and quietly stale on the other - and here it would be worse, because the
|
|
59
|
+
// measurement would drift away from the behaviour it is supposed to be measuring.
|
|
60
|
+
//
|
|
61
|
+
// Order is most-specific-first (URL -> email -> date -> number) so a longer token is not half-consumed
|
|
62
|
+
// by a shorter pattern. URLs and emails are unconditional: an incidental identifier really does want
|
|
63
|
+
// the same answer. Numbers and dates are GATED, default OFF (CACHE_KEY_SLOT_NUMBERS) - a number or a
|
|
64
|
+
// date is very often THE substance of the answer, and on an EXACT cache (no similarity threshold, a
|
|
65
|
+
// guaranteed match) that is a correctness bug rather than a tuning knob.
|
|
66
|
+
const SLOTTERS = [
|
|
67
|
+
{ name: 'url', re: /(?:https?:\/\/|www\.)\S+/gi, gated: false },
|
|
68
|
+
{ name: 'email', re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, gated: false },
|
|
69
|
+
{ name: 'date', re: /\b\d{4}-\d{2}-\d{2}\b/g, gated: true },
|
|
70
|
+
{ name: 'date', re: /\b\d{1,2}\/\d{1,2}\/\d{2,4}\b/g, gated: true },
|
|
71
|
+
{ name: 'number', re: /\b\d+(?:\.\d+)?\b/g, gated: true }
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
function numbersSlottingEnabled() {
|
|
75
|
+
return process.env.CACHE_KEY_SLOT_NUMBERS === 'true';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// punctuation folding, whitespace collapse - everything that happens BEFORE slotting.
|
|
79
|
+
function foldText(text) {
|
|
80
|
+
return String(text)
|
|
81
|
+
.normalize('NFC')
|
|
82
|
+
.replace(/\u00A0/g, ' ') // non-breaking space
|
|
83
|
+
.replace(/[\u2018\u2019\u201A\u201B]/g, "'") // curly single quotes
|
|
84
|
+
.replace(/[\u201C\u201D\u201E\u201F]/g, '"') // curly double quotes
|
|
85
|
+
.replace(/[\u2013\u2014]/g, '-') // en/em dash
|
|
86
|
+
.replace(/\u2026/g, '...') // ellipsis
|
|
87
|
+
.replace(/\s+/g, ' ')
|
|
88
|
+
.trim();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normalizeText(text) {
|
|
92
|
+
let out = foldText(text);
|
|
93
|
+
for (const slotter of SLOTTERS) {
|
|
94
|
+
if (slotter.gated && !numbersSlottingEnabled()) continue;
|
|
95
|
+
// String.prototype.replace with a /g regex resets lastIndex, so sharing these regex objects
|
|
96
|
+
// between calls is safe (unlike .test()/.exec(), which are stateful - hence match() below).
|
|
97
|
+
out = out.replace(slotter.re, '<var>');
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Which slotting steps WOULD fire for this request, for the R4 measurement.
|
|
103
|
+
//
|
|
104
|
+
// Reports gated steps even when the gate is off, on purpose: that is the only way to price turning
|
|
105
|
+
// number/date slotting ON without turning it on, which is exactly the shape of the decision waiting
|
|
106
|
+
// for data. It reports what the rules MATCH, not what changed the key - a prompt with no URL and one
|
|
107
|
+
// with three URLs are both "url fired", and the hit-rate split is what is being measured.
|
|
108
|
+
function slottingFlags(messages) {
|
|
109
|
+
const flags = { url: 0, email: 0, date: 0, number: 0, numbers_gate: numbersSlottingEnabled() ? 1 : 0 };
|
|
110
|
+
if (!Array.isArray(messages)) return flags;
|
|
111
|
+
for (const message of messages) {
|
|
112
|
+
if (!message || typeof message.content !== 'string') continue;
|
|
113
|
+
const folded = foldText(message.content);
|
|
114
|
+
for (const slotter of SLOTTERS) {
|
|
115
|
+
if (flags[slotter.name]) continue; // already known to fire; no need to scan again
|
|
116
|
+
if (folded.match(slotter.re)) flags[slotter.name] = 1;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return flags;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The fields that change the SHAPE of an answer rather than its content, defined ONCE because two
|
|
123
|
+
// paths consume them: buildCacheKey folds them into the exact-cache key, and semanticCache.js stores
|
|
124
|
+
// them beside an entry and refuses a hit across a mismatch. Adding them one at a time is precisely how
|
|
125
|
+
// the two paths drifted apart - this key learned about response_format and the semantic path never did
|
|
126
|
+
// - so the list lives in one place and both call it.
|
|
127
|
+
//
|
|
128
|
+
// temperature is deliberately ABSENT: it already participates in the exact key with a real numeric
|
|
129
|
+
// default (`?? 0.0`), and semantic matching is approximate by design, so gating on it there would
|
|
130
|
+
// mostly lower the hit rate for callers who leave it at the default. seed IS here: it is a determinism
|
|
131
|
+
// contract, not a sampling hint.
|
|
132
|
+
//
|
|
133
|
+
// Key ORDER matters - it is part of the serialization the hash is taken over - and it is kept
|
|
134
|
+
// identical to the inline field list this replaced. JSON.stringify drops undefined values, so a payload
|
|
135
|
+
// with no seed produces a byte-identical key and the change does not flush the cache.
|
|
136
|
+
function shapeFields(payload) {
|
|
137
|
+
return {
|
|
138
|
+
tools: payload.tools,
|
|
139
|
+
tool_choice: payload.tool_choice,
|
|
140
|
+
response_format: payload.response_format,
|
|
141
|
+
seed: payload.seed
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
25
145
|
function buildCacheKey(scope, payload) {
|
|
146
|
+
const canonicalMessages = normalizeMessages(payload.messages);
|
|
147
|
+
if (process.env.CACHE_KEY_DEBUG) {
|
|
148
|
+
// Traceable, not silent (step 21.2): a false-positive collision can be
|
|
149
|
+
// reconstructed by re-running normalizeMessages on the original - this
|
|
150
|
+
// opt-in log line just makes it visible without that extra step.
|
|
151
|
+
console.log('[cache] canonical messages:', JSON.stringify(canonicalMessages));
|
|
152
|
+
}
|
|
26
153
|
const normalized = JSON.stringify({
|
|
27
154
|
...(scope != null ? { scope } : {}),
|
|
28
155
|
model: payload.model,
|
|
29
|
-
messages:
|
|
156
|
+
messages: canonicalMessages,
|
|
30
157
|
temperature: payload.temperature ?? 0.0,
|
|
31
158
|
max_tokens: payload.max_tokens,
|
|
32
|
-
|
|
33
|
-
tool_choice: payload.tool_choice,
|
|
34
|
-
// response_format changes the SHAPE of the answer (json_object vs
|
|
35
|
-
// plain text), so it must participate in the key too - otherwise a
|
|
36
|
-
// cached plain-text response could be served to a json_object caller
|
|
37
|
-
// (or vice versa). openai.js forwards it (see its own chat()); this
|
|
38
|
-
// file used to omit it, making an "exact" hit not always exact.
|
|
39
|
-
response_format: payload.response_format
|
|
159
|
+
...shapeFields(payload)
|
|
40
160
|
});
|
|
41
161
|
const hash = crypto.createHash('sha256').update(normalized).digest('hex');
|
|
42
162
|
const prefix = scope != null ? `ROUTER:${scope}:` : 'ROUTER:';
|
|
@@ -45,6 +165,10 @@ function buildCacheKey(scope, payload) {
|
|
|
45
165
|
|
|
46
166
|
module.exports = {
|
|
47
167
|
buildCacheKey,
|
|
168
|
+
shapeFields,
|
|
169
|
+
slottingFlags,
|
|
170
|
+
normalizeMessages,
|
|
171
|
+
normalizeText,
|
|
48
172
|
|
|
49
173
|
isConnected() {
|
|
50
174
|
return redis.isConnected();
|