cachegate 1.0.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/.dockerignore +11 -0
- package/.env.example +112 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +33 -0
- package/.github/ISSUE_TEMPLATE/config.yml +8 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +29 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +25 -0
- package/.github/workflows/test.yml +63 -0
- package/CODE_OF_CONDUCT.md +66 -0
- package/CONTRIBUTING.md +94 -0
- package/Dockerfile +16 -0
- package/LICENSE +21 -0
- package/OPEN_SOURCE_ROADMAP.md +554 -0
- package/README.md +428 -0
- package/ROADMAP.md +281 -0
- package/SECURITY.md +39 -0
- package/cache.js +51 -0
- package/embeddings.js +32 -0
- package/failover.js +76 -0
- package/metrics.js +556 -0
- package/package.json +26 -0
- package/providers/anthropic.js +122 -0
- package/providers/openai.js +114 -0
- package/public/dashboard.html +1119 -0
- package/redisClient.js +45 -0
- package/router.js +218 -0
- package/semanticCache.js +154 -0
- package/server.js +668 -0
- package/streaming.js +77 -0
- package/sync-oss-release.sh +160 -0
- package/test/auth-config.test.js +27 -0
- package/test/cache.test.js +33 -0
- package/test/embeddings.test.js +24 -0
- package/test/failover.test.js +99 -0
- package/test/metrics-postgres.test.js +183 -0
- package/test/metrics.test.js +282 -0
- package/test/router.test.js +195 -0
- package/test/semanticCache.test.js +167 -0
- package/test/server.test.js +357 -0
- package/test/streaming.test.js +248 -0
package/redisClient.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// model-router/redisClient.js
|
|
2
|
+
//
|
|
3
|
+
// One shared Redis connection, used by both the exact-match cache
|
|
4
|
+
// (cache.js) and the semantic cache (semanticCache.js). Previously
|
|
5
|
+
// cache.js opened and owned this connection privately; pulled out here
|
|
6
|
+
// so the semantic cache doesn't open a second connection to the same
|
|
7
|
+
// Redis instance for the same purpose.
|
|
8
|
+
|
|
9
|
+
const { createClient } = require('redis');
|
|
10
|
+
|
|
11
|
+
const client = createClient({
|
|
12
|
+
url: process.env.REDIS_URL || 'redis://localhost:6379'
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
client.on('error', (err) => {
|
|
16
|
+
console.warn('⚠️ Redis client error:', err?.message || err?.code || String(err));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
let readyResolve;
|
|
20
|
+
// Resolves once the initial connection attempt finishes, success or
|
|
21
|
+
// failure - tests await this instead of polling isConnected() in a
|
|
22
|
+
// loop. Normal request handling doesn't need it: get/set/find/store all
|
|
23
|
+
// already check isConnected() and degrade gracefully.
|
|
24
|
+
const ready = new Promise((resolve) => { readyResolve = resolve; });
|
|
25
|
+
|
|
26
|
+
(async () => {
|
|
27
|
+
try {
|
|
28
|
+
if (process.env.REDIS_URL && !client.isOpen) {
|
|
29
|
+
await client.connect();
|
|
30
|
+
console.log('✅ Model Router connected to Redis');
|
|
31
|
+
} else if (!process.env.REDIS_URL) {
|
|
32
|
+
console.warn('⚠️ REDIS_URL not set. Caching disabled.');
|
|
33
|
+
}
|
|
34
|
+
} catch (err) {
|
|
35
|
+
console.warn('⚠️ Failed to connect to Redis:', err.message);
|
|
36
|
+
} finally {
|
|
37
|
+
readyResolve();
|
|
38
|
+
}
|
|
39
|
+
})();
|
|
40
|
+
|
|
41
|
+
function isConnected() {
|
|
42
|
+
return client.isOpen;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { client, isConnected, ready };
|
package/router.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// model-router/router.js
|
|
2
|
+
//
|
|
3
|
+
// The actual routing decision. Before this file existed, "the router"
|
|
4
|
+
// only dispatched: it read the model name the caller already sent
|
|
5
|
+
// (e.g. "claude-sonnet-4-5-20250929") and forwarded to whichever
|
|
6
|
+
// provider owns that name. That's not routing - the caller had already
|
|
7
|
+
// made the choice. This file adds the thing the product is named for:
|
|
8
|
+
// given a REQUEST FOR A CAPABILITY (not a specific vendor's model), pick
|
|
9
|
+
// a currently-healthy provider that can serve it - by cost (default),
|
|
10
|
+
// by latency, or by cost with a latency guard rail; see ROUTER_STRATEGY
|
|
11
|
+
// below for what each one actually does and doesn't guarantee.
|
|
12
|
+
//
|
|
13
|
+
// Backward compatibility is deliberate: a caller that already names a
|
|
14
|
+
// concrete model (any name not starting with "router:") is dispatched
|
|
15
|
+
// exactly as before, unchanged, in server.js. Nothing here overrides an
|
|
16
|
+
// explicit choice - virtual models are opt-in.
|
|
17
|
+
|
|
18
|
+
const anthropicProvider = require('./providers/anthropic');
|
|
19
|
+
const openaiProvider = require('./providers/openai');
|
|
20
|
+
const metrics = require('./metrics');
|
|
21
|
+
|
|
22
|
+
// A tier groups equivalent-capability models across providers - the
|
|
23
|
+
// deployer's judgment call about which models belong in the same
|
|
24
|
+
// bucket, not a claim this router can verify. Within a tier, selection
|
|
25
|
+
// is ALWAYS by estimated cost, full stop - never by some notion of
|
|
26
|
+
// "quality." That used to be fuzzy: a tier named "router:best" implied
|
|
27
|
+
// picking the best model, but pickCandidate() only ever compared cost,
|
|
28
|
+
// so it silently picked whichever candidate was cheaper - cheaper, not
|
|
29
|
+
// better. Naming it "frontier" instead of "best" says what's actually
|
|
30
|
+
// true: this is the pool of frontier-capability models the deployer
|
|
31
|
+
// trusts, and the router's only job is finding the cheapest healthy one
|
|
32
|
+
// in that pool. If a deployment genuinely needs "always this specific
|
|
33
|
+
// model regardless of price," that's what naming a concrete model
|
|
34
|
+
// directly (skipping "router:" tiers entirely) is for.
|
|
35
|
+
const DEFAULT_TIERS = {
|
|
36
|
+
'router:fast-cheap': [
|
|
37
|
+
{ provider: 'openai', model: 'gpt-4o-mini' },
|
|
38
|
+
{ provider: 'anthropic', model: 'claude-haiku-4-5-20251001' }
|
|
39
|
+
],
|
|
40
|
+
'router:frontier': [
|
|
41
|
+
{ provider: 'anthropic', model: 'claude-sonnet-4-5-20250929' },
|
|
42
|
+
{ provider: 'openai', model: 'gpt-4o' }
|
|
43
|
+
]
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function loadTiers() {
|
|
47
|
+
if (!process.env.ROUTER_TIERS_JSON) return DEFAULT_TIERS;
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(process.env.ROUTER_TIERS_JSON);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.warn('⚠️ ROUTER_TIERS_JSON is not valid JSON, using defaults:', err.message);
|
|
52
|
+
return DEFAULT_TIERS;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Three strategies, not one blended score. A weighted cost/latency
|
|
57
|
+
// formula LOOKS more sophisticated but is really just a made-up
|
|
58
|
+
// tradeoff dressed up as intelligence - whatever weights it used would
|
|
59
|
+
// be a guess this router has no basis for making on the deployer's
|
|
60
|
+
// behalf. These three are each simple enough to state exactly what they
|
|
61
|
+
// do:
|
|
62
|
+
//
|
|
63
|
+
// cost - (default, unchanged from before) cheapest
|
|
64
|
+
// healthy candidate, full stop.
|
|
65
|
+
// latency - fastest healthy candidate by recent average
|
|
66
|
+
// latency, full stop. Cost isn't considered at
|
|
67
|
+
// all except as a tiebreaker.
|
|
68
|
+
// latency-guarded-cost - cheapest healthy candidate, EXCLUDING any
|
|
69
|
+
// candidate whose recent average latency is
|
|
70
|
+
// more than ROUTER_LATENCY_GUARD_MULTIPLIER
|
|
71
|
+
// (default 3x) slower than the fastest known
|
|
72
|
+
// healthy candidate. A candidate with no
|
|
73
|
+
// latency history yet is never excluded by the
|
|
74
|
+
// guard - it hasn't had a chance to be slow.
|
|
75
|
+
// This is the one genuinely "latency-aware"
|
|
76
|
+
// option that still keeps cost as the primary
|
|
77
|
+
// signal: it's a guard rail against picking
|
|
78
|
+
// something dramatically slower to save a
|
|
79
|
+
// fraction of a cent, not a full re-ranking.
|
|
80
|
+
const VALID_STRATEGIES = ['cost', 'latency', 'latency-guarded-cost'];
|
|
81
|
+
const DEFAULT_STRATEGY = 'cost';
|
|
82
|
+
const LATENCY_GUARD_MULTIPLIER = Number(process.env.ROUTER_LATENCY_GUARD_MULTIPLIER) || 3;
|
|
83
|
+
|
|
84
|
+
function loadStrategy() {
|
|
85
|
+
const raw = (process.env.ROUTER_STRATEGY || DEFAULT_STRATEGY).trim();
|
|
86
|
+
if (!VALID_STRATEGIES.includes(raw)) {
|
|
87
|
+
console.warn(`⚠️ Unknown ROUTER_STRATEGY "${raw}", falling back to "${DEFAULT_STRATEGY}". Valid values: ${VALID_STRATEGIES.join(', ')}`);
|
|
88
|
+
return DEFAULT_STRATEGY;
|
|
89
|
+
}
|
|
90
|
+
return raw;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function byCostAscending(a, b) {
|
|
94
|
+
return a.estimatedCostUsd - b.estimatedCostUsd;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function byLatencyThenCost(a, b) {
|
|
98
|
+
const aLatency = typeof a.avgLatencyMs === 'number' ? a.avgLatencyMs : Infinity;
|
|
99
|
+
const bLatency = typeof b.avgLatencyMs === 'number' ? b.avgLatencyMs : Infinity;
|
|
100
|
+
if (aLatency !== bLatency) return aLatency - bLatency;
|
|
101
|
+
return byCostAscending(a, b); // tiebreak: equal latency (often both unknown) falls back to cost
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Applies the `latency-guarded-cost` guard: drops any candidate whose
|
|
106
|
+
* avgLatencyMs is more than LATENCY_GUARD_MULTIPLIER times the fastest
|
|
107
|
+
* KNOWN healthy candidate's latency. If there's no latency data to
|
|
108
|
+
* compare at all (a fresh deployment with no history yet), the guard
|
|
109
|
+
* has nothing to guard against and every candidate passes through
|
|
110
|
+
* unchanged - this strategy degrades to plain cost-only until real
|
|
111
|
+
* latency data exists.
|
|
112
|
+
*/
|
|
113
|
+
function applyLatencyGuard(pool) {
|
|
114
|
+
const knownLatencies = pool
|
|
115
|
+
.map((c) => c.avgLatencyMs)
|
|
116
|
+
.filter((v) => typeof v === 'number');
|
|
117
|
+
if (knownLatencies.length === 0) return pool;
|
|
118
|
+
|
|
119
|
+
const fastest = Math.min(...knownLatencies);
|
|
120
|
+
const guarded = pool.filter(
|
|
121
|
+
(c) => typeof c.avgLatencyMs !== 'number' || c.avgLatencyMs <= fastest * LATENCY_GUARD_MULTIPLIER
|
|
122
|
+
);
|
|
123
|
+
// The guard is a filter, not a veto - never let it eliminate every
|
|
124
|
+
// candidate (a tier with one badly-behaved provider should still
|
|
125
|
+
// route somewhere rather than error out).
|
|
126
|
+
return guarded.length > 0 ? guarded : pool;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isVirtualModel(model) {
|
|
130
|
+
return typeof model === 'string' && model.startsWith('router:');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function estimatorFor(provider) {
|
|
134
|
+
if (provider === 'anthropic') return anthropicProvider.estimateCost;
|
|
135
|
+
if (provider === 'openai') return openaiProvider.estimateCost;
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// A fixed token assumption used ONLY to compare candidates against each
|
|
140
|
+
// other on a like-for-like basis (same assumed size for every
|
|
141
|
+
// candidate) - it is not a prediction of this request's real size.
|
|
142
|
+
const COMPARISON_INPUT_TOKENS = 1000;
|
|
143
|
+
const COMPARISON_OUTPUT_TOKENS = 500;
|
|
144
|
+
|
|
145
|
+
// A provider whose recent error rate is at or above this is treated as
|
|
146
|
+
// unhealthy and skipped unless every candidate in the tier is unhealthy
|
|
147
|
+
// (in which case we still have to pick one - see pickCandidate).
|
|
148
|
+
const UNHEALTHY_ERROR_RATE = 0.5;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Choose a {provider, model} pair for a virtual model name. Always
|
|
152
|
+
* excludes unhealthy candidates first (recent error rate too high,
|
|
153
|
+
* unless every candidate is unhealthy - see below); the strategy (env
|
|
154
|
+
* ROUTER_STRATEGY, default "cost") decides how what's left gets ranked.
|
|
155
|
+
* See the strategy comment above loadStrategy() for what each one
|
|
156
|
+
* actually does.
|
|
157
|
+
*/
|
|
158
|
+
async function pickCandidate(virtualModel) {
|
|
159
|
+
const tiers = loadTiers();
|
|
160
|
+
const candidates = tiers[virtualModel];
|
|
161
|
+
if (!candidates || candidates.length === 0) {
|
|
162
|
+
return { error: `Unknown routing tier: ${virtualModel}` };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const stats = await metrics.providerStats();
|
|
166
|
+
|
|
167
|
+
const scored = candidates.map((candidate) => {
|
|
168
|
+
const estimate = estimatorFor(candidate.provider);
|
|
169
|
+
const estimatedCostUsd = estimate
|
|
170
|
+
? estimate(candidate.model, COMPARISON_INPUT_TOKENS, COMPARISON_OUTPUT_TOKENS)
|
|
171
|
+
: Infinity;
|
|
172
|
+
const providerStat = stats[candidate.provider] || { errorRate: 0, avgLatencyMs: null, sampleSize: 0 };
|
|
173
|
+
return {
|
|
174
|
+
...candidate,
|
|
175
|
+
estimatedCostUsd,
|
|
176
|
+
errorRate: providerStat.errorRate,
|
|
177
|
+
avgLatencyMs: providerStat.avgLatencyMs,
|
|
178
|
+
healthy: providerStat.errorRate < UNHEALTHY_ERROR_RATE
|
|
179
|
+
};
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const healthy = scored.filter((c) => c.healthy);
|
|
183
|
+
const pool = healthy.length > 0 ? healthy : scored; // all unhealthy: pick the least-bad rather than fail outright
|
|
184
|
+
|
|
185
|
+
const strategy = loadStrategy();
|
|
186
|
+
let ranked;
|
|
187
|
+
let guardApplied = false;
|
|
188
|
+
if (strategy === 'latency') {
|
|
189
|
+
ranked = [...pool].sort(byLatencyThenCost);
|
|
190
|
+
} else if (strategy === 'latency-guarded-cost') {
|
|
191
|
+
const guarded = applyLatencyGuard(pool);
|
|
192
|
+
guardApplied = guarded.length < pool.length;
|
|
193
|
+
ranked = [...guarded].sort(byCostAscending);
|
|
194
|
+
} else {
|
|
195
|
+
ranked = [...pool].sort(byCostAscending);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const chosen = ranked[0];
|
|
199
|
+
return {
|
|
200
|
+
provider: chosen.provider,
|
|
201
|
+
model: chosen.model,
|
|
202
|
+
// Same order `chosen` was drawn from, stripped down to just
|
|
203
|
+
// {provider, model} - lets a caller (server.js's failover loop)
|
|
204
|
+
// retry the next-best candidate if the top choice's live call
|
|
205
|
+
// fails, without re-running this scoring/health/strategy pass a
|
|
206
|
+
// second time. Always has at least one entry when `chosen` does.
|
|
207
|
+
rankedCandidates: ranked.map((c) => ({ provider: c.provider, model: c.model })),
|
|
208
|
+
reason: {
|
|
209
|
+
consideredTier: virtualModel,
|
|
210
|
+
strategy,
|
|
211
|
+
candidates: scored,
|
|
212
|
+
allUnhealthy: healthy.length === 0,
|
|
213
|
+
latencyGuardExcludedACandidate: guardApplied
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
module.exports = { isVirtualModel, pickCandidate, loadTiers, loadStrategy };
|
package/semanticCache.js
ADDED
|
@@ -0,0 +1,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
|
+
function listKey(model) {
|
|
41
|
+
return `SEMANTIC_LIST:${model}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function cosineSimilarity(a, b) {
|
|
45
|
+
if (!a || !b || a.length !== b.length) return 0;
|
|
46
|
+
let dot = 0;
|
|
47
|
+
let normA = 0;
|
|
48
|
+
let normB = 0;
|
|
49
|
+
for (let i = 0; i < a.length; i++) {
|
|
50
|
+
dot += a[i] * b[i];
|
|
51
|
+
normA += a[i] * a[i];
|
|
52
|
+
normB += b[i] * b[i];
|
|
53
|
+
}
|
|
54
|
+
if (normA === 0 || normB === 0) return 0;
|
|
55
|
+
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The text a semantic match is based on: just the conversation content,
|
|
60
|
+
* not incidental request parameters (temperature, max_tokens) that
|
|
61
|
+
* don't change what's actually being asked.
|
|
62
|
+
*/
|
|
63
|
+
function extractPromptText(payload) {
|
|
64
|
+
return payload.messages
|
|
65
|
+
.map((m) => `${m.role}: ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`)
|
|
66
|
+
.join('\n');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isEnabled(embeddings = embeddingsDefault) {
|
|
70
|
+
if (process.env.SEMANTIC_CACHE_ENABLED === 'false') return false;
|
|
71
|
+
return redis.isConnected() && embeddings.isEnabled();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Tool-calling requests are excluded from semantic caching: an
|
|
75
|
+
// approximate text match can't guarantee the exact argument values a
|
|
76
|
+
// tool call needs, and returning a plausible-but-wrong tool call is a
|
|
77
|
+
// worse failure than a cache miss.
|
|
78
|
+
function isCacheable(payload) {
|
|
79
|
+
return !payload.tools;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function findMatch(payload, { threshold = DEFAULT_THRESHOLD, embeddings = embeddingsDefault } = {}) {
|
|
83
|
+
if (!isEnabled(embeddings) || !isCacheable(payload)) return null;
|
|
84
|
+
|
|
85
|
+
let queryEmbedding;
|
|
86
|
+
try {
|
|
87
|
+
queryEmbedding = await embeddings.embed(extractPromptText(payload));
|
|
88
|
+
} catch (err) {
|
|
89
|
+
console.warn('⚠️ Semantic cache lookup failed to embed, skipping:', err.message);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let raw;
|
|
94
|
+
try {
|
|
95
|
+
raw = await redis.client.lRange(listKey(payload.model), 0, MAX_CANDIDATES_PER_MODEL - 1);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
console.warn('⚠️ Semantic cache lookup failed:', err.message);
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let best = null;
|
|
102
|
+
for (const line of raw) {
|
|
103
|
+
let record;
|
|
104
|
+
try {
|
|
105
|
+
record = JSON.parse(line);
|
|
106
|
+
} catch {
|
|
107
|
+
continue; // a malformed entry is skipped, not fatal
|
|
108
|
+
}
|
|
109
|
+
const similarity = cosineSimilarity(queryEmbedding, record.embedding);
|
|
110
|
+
if (similarity >= threshold && (!best || similarity > best.similarity)) {
|
|
111
|
+
best = { entry: record.entry, similarity };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return best;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function store(payload, entry, { ttlSeconds = DEFAULT_TTL_SECONDS, embeddings = embeddingsDefault } = {}) {
|
|
118
|
+
if (!isEnabled(embeddings) || !isCacheable(payload)) return false;
|
|
119
|
+
|
|
120
|
+
let embedding;
|
|
121
|
+
try {
|
|
122
|
+
embedding = await embeddings.embed(extractPromptText(payload));
|
|
123
|
+
} catch (err) {
|
|
124
|
+
console.warn('⚠️ Semantic cache store failed to embed, skipping:', err.message);
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const key = listKey(payload.model);
|
|
129
|
+
try {
|
|
130
|
+
await redis.client.lPush(key, JSON.stringify({ embedding, entry, storedAt: Date.now() }));
|
|
131
|
+
await redis.client.lTrim(key, 0, MAX_CANDIDATES_PER_MODEL - 1);
|
|
132
|
+
// A rolling TTL on the whole per-model bucket, reset on every
|
|
133
|
+
// store - simple and predictable (as long as there's traffic to
|
|
134
|
+
// that model, the bucket stays warm; if it goes quiet for
|
|
135
|
+
// ttlSeconds, the whole bucket - old and new entries alike -
|
|
136
|
+
// expires together), not a precise per-entry TTL. Documented
|
|
137
|
+
// tradeoff, not an oversight.
|
|
138
|
+
await redis.client.expire(key, ttlSeconds);
|
|
139
|
+
return true;
|
|
140
|
+
} catch (err) {
|
|
141
|
+
console.warn('⚠️ Semantic cache store failed:', err.message);
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = {
|
|
147
|
+
isEnabled,
|
|
148
|
+
isCacheable,
|
|
149
|
+
findMatch,
|
|
150
|
+
store,
|
|
151
|
+
cosineSimilarity,
|
|
152
|
+
extractPromptText,
|
|
153
|
+
DEFAULT_THRESHOLD
|
|
154
|
+
};
|