cachegate 1.1.0 → 1.2.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 +127 -112
- package/README.md +31 -13
- package/cache.js +72 -51
- package/embeddings.js +42 -32
- package/metrics.js +609 -556
- package/package.json +15 -1
- package/redisClient.js +55 -45
- package/router.js +254 -218
- package/semanticCache.js +159 -154
- package/server.js +282 -113
- package/.dockerignore +0 -11
- package/.gitattributes +0 -12
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -33
- package/.github/ISSUE_TEMPLATE/config.yml +0 -5
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -29
- package/.github/PULL_REQUEST_TEMPLATE.md +0 -25
- package/.github/workflows/test.yml +0 -63
- package/CODE_OF_CONDUCT.md +0 -66
- package/CONTRIBUTING.md +0 -94
- package/Dockerfile +0 -24
- package/OPEN_SOURCE_ROADMAP.md +0 -855
- package/ROADMAP.md +0 -281
- package/SECURITY.md +0 -39
- package/sync-oss-release.sh +0 -160
- package/test/auth-config.test.js +0 -27
- package/test/cache.test.js +0 -33
- package/test/embeddings.test.js +0 -24
- package/test/env-path.test.js +0 -41
- package/test/failover.test.js +0 -99
- package/test/metrics-postgres.test.js +0 -183
- package/test/metrics.test.js +0 -282
- package/test/router.test.js +0 -195
- package/test/semanticCache.test.js +0 -167
- package/test/server.test.js +0 -357
- package/test/streaming.test.js +0 -248
package/semanticCache.js
CHANGED
|
@@ -1,154 +1,159 @@
|
|
|
1
|
-
// model-router/semanticCache.js
|
|
2
|
-
//
|
|
3
|
-
// Catches NEAR-duplicate prompts that cache.js's exact hash match
|
|
4
|
-
// can't: a paraphrase of the same question, reordered context,
|
|
5
|
-
// different whitespace. cache.js stays the first, free, zero-risk
|
|
6
|
-
// check; this one only runs when that misses, and it costs something
|
|
7
|
-
// real every time it runs - one embedding call - whether or not it
|
|
8
|
-
// finds a match. That's a genuine tradeoff, not free money: it's worth
|
|
9
|
-
// it only when near-duplicate traffic is common enough that avoiding
|
|
10
|
-
// the occasional full completion call outweighs the embedding calls
|
|
11
|
-
// spent looking. See the README for the honest framing of what this
|
|
12
|
-
// can and can't claim.
|
|
13
|
-
//
|
|
14
|
-
// Storage: a plain Redis LIST per model, no RediSearch/vector-search
|
|
15
|
-
// module assumed - most self-hosted Redis instances (including
|
|
16
|
-
// Render's managed Redis) don't have that module. A lookup pulls up to
|
|
17
|
-
// MAX_CANDIDATES_PER_MODEL recent entries for that model and computes
|
|
18
|
-
// cosine similarity IN NODE, not in Redis. This is brute-force, not
|
|
19
|
-
// indexed - fine at the volume a self-hosted single instance sees, not
|
|
20
|
-
// meant to scale past that cap. A real vector index is the honest next
|
|
21
|
-
// step if traffic outgrows it.
|
|
22
|
-
//
|
|
23
|
-
// The threshold is a probabilistic judgment call, not a guarantee: a
|
|
24
|
-
// "hit" above the threshold is the router's best guess that two
|
|
25
|
-
// prompts want the same answer, not proof they do. Set it too low and
|
|
26
|
-
// it returns confidently wrong answers - the same failure mode that
|
|
27
|
-
// makes vendor-claimed 90%+ cache hit rates suspect (see this project's
|
|
28
|
-
// own market research on real vs. advertised hit rates). Every
|
|
29
|
-
// semantic hit is tracked separately from an exact hit in metrics.js /
|
|
30
|
-
// GET /stats for exactly this reason - the two are not equally
|
|
31
|
-
// trustworthy and shouldn't be blended into one inflated number.
|
|
32
|
-
|
|
33
|
-
const redis = require('./redisClient');
|
|
34
|
-
const embeddingsDefault = require('./embeddings');
|
|
35
|
-
|
|
36
|
-
const MAX_CANDIDATES_PER_MODEL = Number(process.env.SEMANTIC_CACHE_MAX_CANDIDATES) || 200;
|
|
37
|
-
const DEFAULT_TTL_SECONDS = Number(process.env.SEMANTIC_CACHE_TTL_SECONDS) || 3600;
|
|
38
|
-
const DEFAULT_THRESHOLD = Number(process.env.SEMANTIC_CACHE_THRESHOLD) || 0.93;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
function
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
1
|
+
// model-router/semanticCache.js
|
|
2
|
+
//
|
|
3
|
+
// Catches NEAR-duplicate prompts that cache.js's exact hash match
|
|
4
|
+
// can't: a paraphrase of the same question, reordered context,
|
|
5
|
+
// different whitespace. cache.js stays the first, free, zero-risk
|
|
6
|
+
// check; this one only runs when that misses, and it costs something
|
|
7
|
+
// real every time it runs - one embedding call - whether or not it
|
|
8
|
+
// finds a match. That's a genuine tradeoff, not free money: it's worth
|
|
9
|
+
// it only when near-duplicate traffic is common enough that avoiding
|
|
10
|
+
// the occasional full completion call outweighs the embedding calls
|
|
11
|
+
// spent looking. See the README for the honest framing of what this
|
|
12
|
+
// can and can't claim.
|
|
13
|
+
//
|
|
14
|
+
// Storage: a plain Redis LIST per model, no RediSearch/vector-search
|
|
15
|
+
// module assumed - most self-hosted Redis instances (including
|
|
16
|
+
// Render's managed Redis) don't have that module. A lookup pulls up to
|
|
17
|
+
// MAX_CANDIDATES_PER_MODEL recent entries for that model and computes
|
|
18
|
+
// cosine similarity IN NODE, not in Redis. This is brute-force, not
|
|
19
|
+
// indexed - fine at the volume a self-hosted single instance sees, not
|
|
20
|
+
// meant to scale past that cap. A real vector index is the honest next
|
|
21
|
+
// step if traffic outgrows it.
|
|
22
|
+
//
|
|
23
|
+
// The threshold is a probabilistic judgment call, not a guarantee: a
|
|
24
|
+
// "hit" above the threshold is the router's best guess that two
|
|
25
|
+
// prompts want the same answer, not proof they do. Set it too low and
|
|
26
|
+
// it returns confidently wrong answers - the same failure mode that
|
|
27
|
+
// makes vendor-claimed 90%+ cache hit rates suspect (see this project's
|
|
28
|
+
// own market research on real vs. advertised hit rates). Every
|
|
29
|
+
// semantic hit is tracked separately from an exact hit in metrics.js /
|
|
30
|
+
// GET /stats for exactly this reason - the two are not equally
|
|
31
|
+
// trustworthy and shouldn't be blended into one inflated number.
|
|
32
|
+
|
|
33
|
+
const redis = require('./redisClient');
|
|
34
|
+
const embeddingsDefault = require('./embeddings');
|
|
35
|
+
|
|
36
|
+
const MAX_CANDIDATES_PER_MODEL = Number(process.env.SEMANTIC_CACHE_MAX_CANDIDATES) || 200;
|
|
37
|
+
const DEFAULT_TTL_SECONDS = Number(process.env.SEMANTIC_CACHE_TTL_SECONDS) || 3600;
|
|
38
|
+
const DEFAULT_THRESHOLD = Number(process.env.SEMANTIC_CACHE_THRESHOLD) || 0.93;
|
|
39
|
+
|
|
40
|
+
// scope: same seams contract as cache.js's buildCacheKey - null/
|
|
41
|
+
// undefined (every call site in this codebase today) means one global
|
|
42
|
+
// per-model list, byte-identical to before this parameter existed; a
|
|
43
|
+
// non-null scope gets its own list, isolated from every other scope's.
|
|
44
|
+
function listKey(scope, model) {
|
|
45
|
+
return scope != null ? `SEMANTIC_LIST:${scope}:${model}` : `SEMANTIC_LIST:${model}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function cosineSimilarity(a, b) {
|
|
49
|
+
if (!a || !b || a.length !== b.length) return 0;
|
|
50
|
+
let dot = 0;
|
|
51
|
+
let normA = 0;
|
|
52
|
+
let normB = 0;
|
|
53
|
+
for (let i = 0; i < a.length; i++) {
|
|
54
|
+
dot += a[i] * b[i];
|
|
55
|
+
normA += a[i] * a[i];
|
|
56
|
+
normB += b[i] * b[i];
|
|
57
|
+
}
|
|
58
|
+
if (normA === 0 || normB === 0) return 0;
|
|
59
|
+
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The text a semantic match is based on: just the conversation content,
|
|
64
|
+
* not incidental request parameters (temperature, max_tokens) that
|
|
65
|
+
* don't change what's actually being asked.
|
|
66
|
+
*/
|
|
67
|
+
function extractPromptText(payload) {
|
|
68
|
+
return payload.messages
|
|
69
|
+
.map((m) => `${m.role}: ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`)
|
|
70
|
+
.join('\n');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isEnabled(embeddings = embeddingsDefault) {
|
|
74
|
+
if (process.env.SEMANTIC_CACHE_ENABLED === 'false') return false;
|
|
75
|
+
return redis.isConnected() && embeddings.isEnabled();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Tool-calling requests are excluded from semantic caching: an
|
|
79
|
+
// approximate text match can't guarantee the exact argument values a
|
|
80
|
+
// tool call needs, and returning a plausible-but-wrong tool call is a
|
|
81
|
+
// worse failure than a cache miss.
|
|
82
|
+
function isCacheable(payload) {
|
|
83
|
+
return !payload.tools;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function findMatch(scope, payload, { threshold = DEFAULT_THRESHOLD, embeddings = embeddingsDefault } = {}) {
|
|
87
|
+
if (!isEnabled(embeddings) || !isCacheable(payload)) return null;
|
|
88
|
+
|
|
89
|
+
let queryEmbedding;
|
|
90
|
+
try {
|
|
91
|
+
queryEmbedding = await embeddings.embed(extractPromptText(payload));
|
|
92
|
+
} catch (err) {
|
|
93
|
+
console.warn('⚠️ Semantic cache lookup failed to embed, skipping:', err.message);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let raw;
|
|
98
|
+
try {
|
|
99
|
+
raw = await redis.client.lRange(listKey(scope, payload.model), 0, MAX_CANDIDATES_PER_MODEL - 1);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
console.warn('⚠️ Semantic cache lookup failed:', err.message);
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let best = null;
|
|
106
|
+
for (const line of raw) {
|
|
107
|
+
let record;
|
|
108
|
+
try {
|
|
109
|
+
record = JSON.parse(line);
|
|
110
|
+
} catch {
|
|
111
|
+
continue; // a malformed entry is skipped, not fatal
|
|
112
|
+
}
|
|
113
|
+
const similarity = cosineSimilarity(queryEmbedding, record.embedding);
|
|
114
|
+
if (similarity >= threshold && (!best || similarity > best.similarity)) {
|
|
115
|
+
best = { entry: record.entry, similarity };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return best;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function store(scope, payload, entry, { ttlSeconds = DEFAULT_TTL_SECONDS, embeddings = embeddingsDefault } = {}) {
|
|
122
|
+
if (!isEnabled(embeddings) || !isCacheable(payload)) return false;
|
|
123
|
+
|
|
124
|
+
let embedding;
|
|
125
|
+
try {
|
|
126
|
+
embedding = await embeddings.embed(extractPromptText(payload));
|
|
127
|
+
} catch (err) {
|
|
128
|
+
console.warn('⚠️ Semantic cache store failed to embed, skipping:', err.message);
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const key = listKey(scope, payload.model);
|
|
133
|
+
try {
|
|
134
|
+
await redis.client.lPush(key, JSON.stringify({ embedding, entry, storedAt: Date.now() }));
|
|
135
|
+
await redis.client.lTrim(key, 0, MAX_CANDIDATES_PER_MODEL - 1);
|
|
136
|
+
// A rolling TTL on the whole per-model bucket, reset on every
|
|
137
|
+
// store - simple and predictable (as long as there's traffic to
|
|
138
|
+
// that model, the bucket stays warm; if it goes quiet for
|
|
139
|
+
// ttlSeconds, the whole bucket - old and new entries alike -
|
|
140
|
+
// expires together), not a precise per-entry TTL. Documented
|
|
141
|
+
// tradeoff, not an oversight.
|
|
142
|
+
await redis.client.expire(key, ttlSeconds);
|
|
143
|
+
return true;
|
|
144
|
+
} catch (err) {
|
|
145
|
+
console.warn('⚠️ Semantic cache store failed:', err.message);
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = {
|
|
151
|
+
isEnabled,
|
|
152
|
+
isCacheable,
|
|
153
|
+
findMatch,
|
|
154
|
+
store,
|
|
155
|
+
listKey,
|
|
156
|
+
cosineSimilarity,
|
|
157
|
+
extractPromptText,
|
|
158
|
+
DEFAULT_THRESHOLD
|
|
159
|
+
};
|