cachegate 1.3.0 → 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/router.js CHANGED
@@ -147,6 +147,16 @@ const COMPARISON_OUTPUT_TOKENS = 500;
147
147
  // (in which case we still have to pick one - see pickCandidate).
148
148
  const UNHEALTHY_ERROR_RATE = 0.5;
149
149
 
150
+ // Step 33 (health scoring + circuit breakers): a second, STRICTER tier
151
+ // above "unhealthy" - "shed". A candidate at or above SHED_ERROR_RATE is
152
+ // clearly down (not just having a rough patch), so it's fully excluded
153
+ // this request rather than merely deprioritized. avgQualityScore (step 32)
154
+ // is a second, independent confirming signal: a candidate that keeps
155
+ // needing failover to nominally "succeed" sheds even before its raw
156
+ // error-rate count crosses the threshold. Both thresholds are env-tunable.
157
+ const SHED_ERROR_RATE = Number(process.env.ROUTER_SHED_ERROR_RATE) || 0.9;
158
+ const SHED_QUALITY_SCORE = Number(process.env.ROUTER_SHED_QUALITY_SCORE) || 0.2;
159
+
150
160
  // Minimum number of recent requests before a provider's error rate is
151
161
  // treated as meaningful. Without this, a brand-new provider (or one whose
152
162
  // traffic just resumed) with a SINGLE request that happened to error has
@@ -205,18 +215,36 @@ async function pickCandidate(virtualModel, scope) {
205
215
  const estimatedCostUsd = estimate
206
216
  ? estimate(candidate.model, COMPARISON_INPUT_TOKENS, COMPARISON_OUTPUT_TOKENS)
207
217
  : Infinity;
208
- const providerStat = stats[candidate.provider] || { errorRate: 0, avgLatencyMs: null, sampleSize: 0 };
218
+ const providerStat = stats[candidate.provider] || { errorRate: 0, avgLatencyMs: null, avgQualityScore: null, sampleSize: 0 };
219
+ const hasEnoughSamples = providerStat.sampleSize >= MIN_HEALTH_SAMPLES;
220
+ // shed = clearly down (33.1): high error rate, OR low avgQualityScore
221
+ // (keeps failing over to "succeed"). avgQualityScore is null when there
222
+ // is no quality data yet - insufficient data must never shed.
223
+ const shed =
224
+ hasEnoughSamples &&
225
+ (providerStat.errorRate >= SHED_ERROR_RATE ||
226
+ (typeof providerStat.avgQualityScore === 'number' && providerStat.avgQualityScore < SHED_QUALITY_SCORE));
209
227
  return {
210
228
  ...candidate,
211
229
  estimatedCostUsd,
212
230
  errorRate: providerStat.errorRate,
213
231
  avgLatencyMs: providerStat.avgLatencyMs,
214
- healthy: providerStat.sampleSize < MIN_HEALTH_SAMPLES || providerStat.errorRate < UNHEALTHY_ERROR_RATE
232
+ avgQualityScore: providerStat.avgQualityScore,
233
+ shed,
234
+ healthy: !hasEnoughSamples || providerStat.errorRate < UNHEALTHY_ERROR_RATE
215
235
  };
216
236
  });
217
237
 
218
- const healthy = scored.filter((c) => c.healthy);
219
- const pool = healthy.length > 0 ? healthy : scored; // all unhealthy: pick the least-bad rather than fail outright
238
+ // 33.1: shed the clearly-down candidates first (stricter than unhealthy).
239
+ const notShed = scored.filter((c) => !c.shed);
240
+ // 33.2: all-down fallback - if every candidate is shed, fall back to the
241
+ // full list and try anyway (least-bad rather than fail outright), the
242
+ // same philosophy as the all-unhealthy fallback below.
243
+ const afterShed = notShed.length > 0 ? notShed : scored;
244
+ // Existing unhealthy filter (a rough patch, 0.5 <= errorRate < 0.9) on
245
+ // what remains after shedding.
246
+ const healthy = afterShed.filter((c) => c.healthy);
247
+ const pool = healthy.length > 0 ? healthy : afterShed; // all unhealthy: pick the least-bad rather than fail outright
220
248
 
221
249
  const strategy = loadStrategy();
222
250
  let ranked;
@@ -245,6 +273,7 @@ async function pickCandidate(virtualModel, scope) {
245
273
  consideredTier: virtualModel,
246
274
  strategy,
247
275
  candidates: scored,
276
+ shedExcludedACandidate: notShed.length < scored.length,
248
277
  allUnhealthy: healthy.length === 0,
249
278
  latencyGuardExcludedACandidate: guardApplied
250
279
  }
package/semanticCache.js CHANGED
@@ -20,6 +20,19 @@
20
20
  // meant to scale past that cap. A real vector index is the honest next
21
21
  // step if traffic outgrows it.
22
22
  //
23
+ // What that brute force actually COSTS was worth measuring rather than
24
+ // assuming, and it was not the arithmetic: each entry used to carry its
25
+ // embedding as JSON text, so a 1536-dim vector arrived as ~30 KB of
26
+ // digits and a lookup parsed up to 200 of them - 5.89 MB of JSON
27
+ // number-parsing per semantic miss, against 200 x 1536 multiply-adds to
28
+ // score them (48.7 ms, measured). The vector is now stored as base64
29
+ // Float32 with its L2 norm beside it: 1.58 MB and 3.6 ms for the same
30
+ // work, 13x faster. Entries written before this change are still read
31
+ // correctly - both formats live in the same list until the old ones age
32
+ // out - and the cap, the threshold and the honesty framing above are
33
+ // unchanged. This is an encoding improvement, NOT the vector index the
34
+ // last paragraph is still waiting for.
35
+ //
23
36
  // The threshold is a probabilistic judgment call, not a guarantee: a
24
37
  // "hit" above the threshold is the router's best guess that two
25
38
  // prompts want the same answer, not proof they do. Set it too low and
@@ -32,6 +45,15 @@
32
45
 
33
46
  const redis = require('./redisClient');
34
47
  const embeddingsDefault = require('./embeddings');
48
+ // The answer-shape definition is shared with the exact cache ON PURPOSE: the two paths diverged once -
49
+ // cache.js keyed response_format and this file never did - and a field added to one of them silently
50
+ // stops being enforced by the other. No cycle: cache.js does not require this file.
51
+ const { shapeFields } = require('./cache');
52
+
53
+ // The shape of a request that demands nothing about the answer's form: no response_format, no tools,
54
+ // no tool_choice, no seed. The semantic shape gate uses it to decide whether an entry carrying no
55
+ // recorded shape may still be served - see the gate in findMatch.
56
+ const SHAPE_OF_NEUTRAL = JSON.stringify(shapeFields({}));
35
57
 
36
58
  const MAX_CANDIDATES_PER_MODEL = Number(process.env.SEMANTIC_CACHE_MAX_CANDIDATES) || 200;
37
59
  const DEFAULT_TTL_SECONDS = Number(process.env.SEMANTIC_CACHE_TTL_SECONDS) || 3600;
@@ -45,6 +67,44 @@ function listKey(scope, model) {
45
67
  return scope != null ? `SEMANTIC_LIST:${scope}:${model}` : `SEMANTIC_LIST:${model}`;
46
68
  }
47
69
 
70
+ // Embeddings are stored as base64 Float32 rather than JSON numbers. Float32 loses ~7 significant digits
71
+ // versus the JSON doubles the old format held, and cosine similarity over 1536 dimensions cannot see the
72
+ // difference - test/semanticCache.test.js asserts both paths agree, and the legacy branch below is what
73
+ // keeps entries written before this change matching correctly.
74
+ function encodeEmbedding(vec) {
75
+ const f = Float32Array.from(vec);
76
+ return Buffer.from(f.buffer, f.byteOffset, f.byteLength).toString('base64');
77
+ }
78
+
79
+ function decodeEmbedding(record) {
80
+ const e = record && record.embedding;
81
+ if (Array.isArray(e)) return e; // legacy: JSON doubles
82
+ if (typeof e !== 'string') return null;
83
+ const buf = Buffer.from(e, 'base64');
84
+ // A Float32 vector is 4 bytes per element; anything else is not one. Checked rather than assumed so a
85
+ // corrupt or truncated entry is skipped (as a malformed JSON entry already was) instead of producing a
86
+ // wrong-length array that silently scores 0 against everything.
87
+ if (buf.length === 0 || buf.length % 4 !== 0) return null;
88
+ return new Float32Array(buf.buffer, buf.byteOffset, buf.length / 4);
89
+ }
90
+
91
+ function normOf(v) {
92
+ let n = 0;
93
+ for (let i = 0; i < v.length; i++) n += v[i] * v[i];
94
+ return Math.sqrt(n);
95
+ }
96
+
97
+ // Same result as cosineSimilarity(), given the two norms the caller already has or has stored. The stored
98
+ // norm is the point: without it, every lookup recomputes the norm of every candidate - a third of the float
99
+ // work per candidate, for a value that never changes.
100
+ function similarityWithNorms(a, aNorm, b, bNorm) {
101
+ if (!a || !b || a.length !== b.length) return 0;
102
+ if (aNorm === 0 || bNorm === 0) return 0;
103
+ let dot = 0;
104
+ for (let i = 0; i < a.length; i++) dot += a[i] * b[i];
105
+ return dot / (aNorm * bNorm);
106
+ }
107
+
48
108
  function cosineSimilarity(a, b) {
49
109
  if (!a || !b || a.length !== b.length) return 0;
50
110
  let dot = 0;
@@ -79,6 +139,11 @@ function isEnabled(embeddings = embeddingsDefault) {
79
139
  // approximate text match can't guarantee the exact argument values a
80
140
  // tool call needs, and returning a plausible-but-wrong tool call is a
81
141
  // worse failure than a cache miss.
142
+ //
143
+ // response_format is NOT excluded, it is FILTERED (see findMatch): the
144
+ // answer is still worth caching, it just may not be served to a caller
145
+ // who asked for a different shape. Excluding it outright would throw away
146
+ // hit rate to solve a matching problem.
82
147
  function isCacheable(payload) {
83
148
  return !payload.tools;
84
149
  }
@@ -86,6 +151,11 @@ function isCacheable(payload) {
86
151
  async function findMatch(scope, payload, { threshold = DEFAULT_THRESHOLD, embeddings = embeddingsDefault } = {}) {
87
152
  if (!isEnabled(embeddings) || !isCacheable(payload)) return null;
88
153
 
154
+ // The shape this request needs. A semantic match is approximate about the PROMPT, never about the
155
+ // answer's shape: a json_object caller served a cached plain-text answer fails to parse it, which
156
+ // surfaces as an upstream outage rather than a cache miss. Same reasoning that excludes tool calls.
157
+ const shape = JSON.stringify(shapeFields(payload));
158
+
89
159
  let queryEmbedding;
90
160
  try {
91
161
  queryEmbedding = await embeddings.embed(extractPromptText(payload));
@@ -102,7 +172,9 @@ async function findMatch(scope, payload, { threshold = DEFAULT_THRESHOLD, embedd
102
172
  return null;
103
173
  }
104
174
 
175
+ const queryNorm = normOf(queryEmbedding);
105
176
  let best = null;
177
+ let shapeSkipped = 0;
106
178
  for (const line of raw) {
107
179
  let record;
108
180
  try {
@@ -110,11 +182,36 @@ async function findMatch(scope, payload, { threshold = DEFAULT_THRESHOLD, embedd
110
182
  } catch {
111
183
  continue; // a malformed entry is skipped, not fatal
112
184
  }
113
- const similarity = cosineSimilarity(queryEmbedding, record.embedding);
185
+ // Shape gate. Refuse a candidate that cannot PROVE the shape matches.
186
+ //
187
+ // The asymmetry is deliberate, and it reconciles two requirements that both have to hold:
188
+ // * A request that demands nothing about the answer's shape can consume either form, so an entry
189
+ // with no recorded shape - stored before this gate existed - is still served. That is what
190
+ // keeps "an upgrade is not a cache flush" true for the plain-prose case, which is the vast
191
+ // majority of traffic.
192
+ // * A request that DOES demand a shape may only be served by an entry that proves it matches.
193
+ // Prose handed to a json_object caller fails to parse in the caller and surfaces as an upstream
194
+ // outage - worse than a miss - and that direction is the entire reason this gate exists.
195
+ // A recorded shape that differs is refused in both directions: the reverse (json handed to a prose
196
+ // caller) does not crash anything, but it is still the wrong answer to the question asked.
197
+ const shapeNeutral = shape === SHAPE_OF_NEUTRAL;
198
+ if (!(record.shape === shape || (record.shape === undefined && shapeNeutral))) {
199
+ shapeSkipped += 1;
200
+ continue;
201
+ }
202
+ const vector = decodeEmbedding(record);
203
+ if (!vector) continue; // ditto an entry whose vector cannot be read
204
+ const candidateNorm = typeof record.norm === 'number' ? record.norm : normOf(vector);
205
+ const similarity = similarityWithNorms(queryEmbedding, queryNorm, vector, candidateNorm);
114
206
  if (similarity >= threshold && (!best || similarity > best.similarity)) {
115
207
  best = { entry: record.entry, similarity };
116
208
  }
117
209
  }
210
+ if (!best && shapeSkipped > 0) {
211
+ // Visible, because "the semantic cache stopped hitting" otherwise looks like a tuning problem
212
+ // rather than the shape gate doing its job.
213
+ console.log(`[semantic] ${shapeSkipped} candidate(s) skipped: answer shape differs from this request`);
214
+ }
118
215
  return best;
119
216
  }
120
217
 
@@ -130,8 +227,17 @@ async function store(scope, payload, entry, { ttlSeconds = DEFAULT_TTL_SECONDS,
130
227
  }
131
228
 
132
229
  const key = listKey(scope, payload.model);
230
+ // The shape is stored BESIDE the entry rather than folded into the key: matching here is by
231
+ // embedding, so there is no key to fold it into. findMatch refuses a candidate whose shape differs.
232
+ const shape = JSON.stringify(shapeFields(payload));
133
233
  try {
134
- await redis.client.lPush(key, JSON.stringify({ embedding, entry, storedAt: Date.now() }));
234
+ await redis.client.lPush(key, JSON.stringify({
235
+ embedding: encodeEmbedding(embedding),
236
+ norm: normOf(embedding),
237
+ shape,
238
+ entry,
239
+ storedAt: Date.now()
240
+ }));
135
241
  await redis.client.lTrim(key, 0, MAX_CANDIDATES_PER_MODEL - 1);
136
242
  // A rolling TTL on the whole per-model bucket, reset on every
137
243
  // store - simple and predictable (as long as there's traffic to
@@ -155,5 +261,11 @@ module.exports = {
155
261
  listKey,
156
262
  cosineSimilarity,
157
263
  extractPromptText,
158
- DEFAULT_THRESHOLD
264
+ DEFAULT_THRESHOLD,
265
+ // exported for test/semanticCache.test.js: the storage format is a compatibility surface (both formats
266
+ // must keep matching), so its round-trip is asserted rather than assumed
267
+ encodeEmbedding,
268
+ decodeEmbedding,
269
+ normOf,
270
+ similarityWithNorms
159
271
  };