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/.env.example +83 -4
- package/LICENSE +21 -21
- 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/failover.js +76 -76
- package/guardrails.js +80 -0
- package/metrics.js +93 -7
- package/package.json +12 -2
- package/pii.js +128 -0
- package/providers/anthropic.js +122 -122
- package/providers/deepseek.js +194 -0
- package/providers/index.js +62 -0
- package/providers/openai.js +122 -114
- package/providers/openrouter.js +233 -0
- package/public/dashboard.html +1120 -1119
- package/router.js +33 -4
- package/semanticCache.js +115 -3
- package/server.js +494 -99
- package/streaming.js +77 -77
- package/tracing.js +97 -0
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
|
-
|
|
232
|
+
avgQualityScore: providerStat.avgQualityScore,
|
|
233
|
+
shed,
|
|
234
|
+
healthy: !hasEnoughSamples || providerStat.errorRate < UNHEALTHY_ERROR_RATE
|
|
215
235
|
};
|
|
216
236
|
});
|
|
217
237
|
|
|
218
|
-
|
|
219
|
-
const
|
|
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
|
-
|
|
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({
|
|
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
|
};
|