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/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 };
@@ -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
+ };