cachegate 1.3.1 → 1.4.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/.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 - Anthropic, OpenAI, or both).
20
- # The model itself is named PER REQUEST in the API call's own "model"
21
- # field (see README's "Usage" section), not configured here - there is
22
- # no ANTHROPIC_MODEL/OPENAI_MODEL env var to set.
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
@@ -35,17 +35,16 @@ fills instead:
35
35
 
36
36
  - **Not a hosted service.** There's no cloud offering, no login, no
37
37
  billing, no multi-tenant key custody here — this is the engine you
38
- run yourself. If you want that instead, that's a separate, closed
39
- product built on top of this same engine — not a fork of this one,
40
- and not something this repository will ever grow into. This project
41
- intentionally doesn't ship the pieces (billing, multi-tenant key
42
- custody, a login system) that a competing hosted offering would need,
43
- and isn't looking for PRs that add them (see `CONTRIBUTING.md`'s
44
- scope note) not because the license forbids it (MIT permits
45
- exactly that see `LICENSE`), but because it's not what this project
46
- is for.
47
- - **Not a 140-provider gateway.** Anthropic and OpenAI today (see
48
- "Features" below for the honest current gap against a wider pitch).
38
+ run yourself. This project intentionally doesn't ship the pieces (billing,
39
+ multi-tenant key custody, a login system) that a competing hosted
40
+ offering would need, and isn't looking for PRs that add them (see
41
+ `CONTRIBUTING.md`'s scope note) — not because the license forbids it
42
+ (MIT permits exactly that see `LICENSE`), but because it's not what
43
+ this project is for.
44
+ - **Not a 140-provider gateway.** Four providers today Anthropic, OpenAI,
45
+ DeepSeek and OpenRouter (the last one fronting many vendors behind a single
46
+ key). No Gemini, no Groq, no local models yet; see "Features" below for the
47
+ honest current gap against a wider pitch.
49
48
  - **Not a vector-indexed semantic cache** (yet) — see "Two kinds of
50
49
  cache hit" for the real, disclosed scale limit.
51
50
 
@@ -105,8 +104,10 @@ Create or edit your local `.env` file (do **not** overwrite an existing one):
105
104
  PORT=4000
106
105
  MODEL_ROUTER_INTERNAL_KEY=your-random-internal-key
107
106
  ANTHROPIC_API_KEY=your-real-key-here
108
- # Optional:
107
+ # Optional - any one of these unlocks that provider's models:
109
108
  # OPENAI_API_KEY=your-openai-key-here
109
+ # DEEPSEEK_API_KEY=your-deepseek-key-here # models: deepseek-flash, deepseek-v4-pro
110
+ # OPENROUTER_API_KEY=your-openrouter-key-here # models: any vendor/model id, e.g. meta/llama-3-70b
110
111
  # REDIS_URL=redis://localhost:6379
111
112
  ```
112
113
 
@@ -198,7 +199,9 @@ monorepo today).
198
199
  ## Usage
199
200
 
200
201
  Direct dispatch - name a specific provider's model, same as calling that
201
- provider yourself:
202
+ provider yourself. The provider is chosen from the model name: `claude-*` ->
203
+ Anthropic, `gpt-*`/`o1*`/`o3*` -> OpenAI, `deepseek-*` -> DeepSeek, and
204
+ `vendor/model` -> OpenRouter.
202
205
 
203
206
  ```bash
204
207
  curl http://localhost:4000/v1/chat/completions \
@@ -211,6 +214,13 @@ curl http://localhost:4000/v1/chat/completions \
211
214
  }'
212
215
  ```
213
216
 
217
+ Same call against DeepSeek or OpenRouter - only the `model` changes:
218
+
219
+ ```bash
220
+ -d '{"model": "deepseek-flash", "messages": [{"role": "user", "content": "Say hello"}]}'
221
+ -d '{"model": "meta/llama-3-70b", "messages": [{"role": "user", "content": "Say hello"}]}'
222
+ ```
223
+
214
224
  Routed dispatch - name a capability tier instead, and the router picks
215
225
  the cheapest currently-healthy provider for it:
216
226
 
@@ -278,8 +288,18 @@ auth, for local development only.
278
288
  streaming caller still gets the caching benefit. See "Streaming"
279
289
  below for the real scope boundary (tool-call streaming isn't
280
290
  included) and the cost-tracking detail it depends on.
281
- - Anthropic and OpenAI providers. (Not yet: Gemini, Groq, local models -
282
- a real gap against the two-provider skeleton's original pitch.)
291
+ - Four providers: Anthropic, OpenAI, DeepSeek and OpenRouter. (Not yet:
292
+ Gemini, Groq, local models - still a real gap against a wide-gateway pitch,
293
+ just a smaller one.) Adding another is deliberately cheap now: one module
294
+ under `providers/` plus a line in `providers/index.js` - every dispatch
295
+ path, the model-name detection and the "which key is missing" error all read
296
+ from that registry rather than hardcoding a pair.
297
+ - **DeepSeek pricing is time-aware.** DeepSeek bills input in two tiers (cache
298
+ hit vs miss) and every rate has a peak and an off-peak value, so its cost
299
+ estimates - and therefore cost-based routing - reflect the current billing
300
+ window instead of a single flat rate. Opting into DeepSeek is also the one
301
+ place where prompt caching is billed this aggressively, so `cost_usd` on
302
+ those requests can be dramatically lower than the token count suggests.
283
303
  - Redis-backed exact-match response cache by content hash - the first,
284
304
  free, zero-risk check on every request.
285
305
  - A semantic cache on top of it, for near-duplicate prompts the exact
@@ -318,6 +338,23 @@ auth, for local development only.
318
338
  provider fallback), and the metrics store. They don't call a real
319
339
  provider API - that needs live keys and real spend, out of scope for
320
340
  this suite.
341
+ - **Guardrails - PII redaction and prompt-injection detection**, both
342
+ off by default (opt-in, zero behavior change until you set an env
343
+ var). Runs pre-dispatch, before either cache lookup, so redacted
344
+ content is what gets cached and a blocked request never reaches a
345
+ provider:
346
+ - `GUARDRAILS_PII_REDACTION=true` - pattern-based detection +
347
+ redaction for emails, phone numbers, SSNs, credit card numbers
348
+ (shape + Luhn checksum), and common vendor API-key/secret shapes
349
+ (`pii.js`). Never surfaces the actual matched value anywhere, even
350
+ internally - only a `{type, count}` summary.
351
+ - `GUARDRAILS_ENABLED=true` - heuristic prompt-injection detection
352
+ (instruction-override, system-prompt-leak, role-play jailbreak,
353
+ "developer mode", DAN, "no restrictions" framing - `guardrails.js`).
354
+ Default action on a hit is `flag` (logged, request still proceeds) -
355
+ heuristics false-positive, so auto-blocking real traffic isn't the
356
+ default. Set `GUARDRAILS_INJECTION_ACTION=block` to reject a
357
+ detected attempt with `403` instead.
321
358
 
322
359
  ## Two kinds of cache hit - why they're reported separately
323
360
 
@@ -360,6 +397,99 @@ doesn't have one). That's fine at single-instance, self-hosted volume;
360
397
  it is not built to scale past that cap. See `semanticCache.js` for the
361
398
  full reasoning.
362
399
 
400
+ ## Provider prompt caching + Cachegate's own cache - compounding, not fighting
401
+
402
+ Anthropic and OpenAI both have their own prompt-caching feature,
403
+ separate from anything in this project - a KV-cache the provider
404
+ itself keeps for a repeated, unchanging prefix (a long system prompt,
405
+ a set of few-shot examples, a big shared document) so it doesn't get
406
+ reprocessed on every call. It's easy to assume that overlaps with
407
+ Cachegate's own exact/semantic cache and picking one means giving up
408
+ the other. It doesn't - they solve different problems, and used
409
+ together they compound:
410
+
411
+ - **Cachegate's cache is checked FIRST, before any provider is ever
412
+ called.** An exact or semantic hit costs `$0` and involves the
413
+ provider not at all - strictly better than even a heavily-discounted
414
+ cached-prefix rate, because there's no completion call at all.
415
+ - **Provider-level prompt caching only ever matters on a genuine
416
+ Cachegate miss** - a request different enough (in its varying tail)
417
+ that it doesn't match anything cached, but sharing a long, unchanging
418
+ prefix (system prompt, few-shot examples) with other misses that came
419
+ before it. That's the case Cachegate's own cache structurally can't
420
+ help with - the *tail* differs, so the request as a whole is a miss -
421
+ but the provider's own cache can still skip reprocessing the shared
422
+ *prefix*, cutting cost and latency on every one of those misses.
423
+
424
+ **This already works transparently - no cachegate code change
425
+ needed.** `providers/anthropic.js` and `providers/openai.js` both
426
+ forward your `messages`/`system`/`tools` fields through to the
427
+ provider's SDK as-is; neither reshapes message content or strips
428
+ unrecognized properties from it. Concretely:
429
+
430
+ - **Anthropic**: mark the unchanging part with
431
+ `cache_control: {"type": "ephemeral"}`, same as you would calling
432
+ Anthropic directly - on the system prompt, on a tool definition, or
433
+ on a specific content block within `messages`. Whatever object you
434
+ put there reaches `client.messages.create()` unchanged.
435
+
436
+ ```bash
437
+ curl http://localhost:4000/v1/chat/completions \
438
+ -H "Content-Type: application/json" \
439
+ -H "Authorization: Bearer your-random-internal-key" \
440
+ -d '{
441
+ "model": "claude-sonnet-4-5-20250929",
442
+ "max_tokens": 1024,
443
+ "messages": [
444
+ {
445
+ "role": "system",
446
+ "content": [
447
+ {
448
+ "type": "text",
449
+ "text": "<...your long, unchanging system prompt / few-shot examples...>",
450
+ "cache_control": {"type": "ephemeral"}
451
+ }
452
+ ]
453
+ },
454
+ {"role": "user", "content": "This part changes on every call."}
455
+ ]
456
+ }'
457
+ ```
458
+
459
+ Anthropic's own docs are the source of truth for the minimum
460
+ cacheable prompt length (model-dependent) and the 5-minute default
461
+ TTL - this project doesn't set or override either.
462
+
463
+ - **OpenAI**: fully automatic, no request changes at all. Once a
464
+ prompt's shared prefix is long enough (OpenAI's own current
465
+ threshold; see their docs, not repeated here since it's a number
466
+ they control and could change), OpenAI caches it on their side by
467
+ default - the exact same `messages` array you're already sending
468
+ through `providers/openai.js` unmodified is what makes this work, or
469
+ not, entirely on their end.
470
+
471
+ **One thing worth watching, specific to Cachegate's own exact cache:**
472
+ `cache.buildCacheKey()` hashes your `messages` (and `tools`) as given -
473
+ a `cache_control` block is just another property on a content object,
474
+ and it participates in that hash like anything else. Two requests that
475
+ are otherwise identical but differ only in whether `cache_control` is
476
+ present will land in **different** Cachegate exact-cache entries -
477
+ Cachegate doesn't know the extra field is caching metadata, it's just
478
+ part of the request shape. Not a bug, just a consequence of exact
479
+ meaning exact: keep `cache_control` usage *consistent* across calls
480
+ for the same logical prompt (always include it, or never) rather than
481
+ sometimes adding it, or you'll needlessly fragment Cachegate's own
482
+ cache into two variants of what should be one entry.
483
+
484
+ **If `GUARDRAILS_PII_REDACTION` is on** (see "Features" above): PII
485
+ redaction only ever rewrites a content block's `text` field in place -
486
+ `cache_control` and every other property on that block pass through
487
+ untouched. Redaction changing the *text* of a normally-stable shared
488
+ prefix would still be an unusual thing to have happen (a system prompt
489
+ containing PII isn't a common shape), but if it ever does, the
490
+ redacted version is what both Cachegate's cache key and the provider's
491
+ own cache lookup see - consistently, on every call, not just some.
492
+
363
493
  ## Streaming
364
494
 
365
495
  `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: payload.messages,
156
+ messages: canonicalMessages,
30
157
  temperature: payload.temperature ?? 0.0,
31
158
  max_tokens: payload.max_tokens,
32
- tools: payload.tools,
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();