cachegate 1.1.1 → 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.
@@ -1,195 +0,0 @@
1
- const { test } = require('node:test');
2
- const assert = require('node:assert/strict');
3
- const fs = require('fs');
4
- const os = require('os');
5
- const path = require('path');
6
-
7
- // metrics.js and router.js both read env vars once at require time, so
8
- // each test gets a fresh, isolated metrics log by clearing the require
9
- // cache and pointing METRICS_LOG_PATH at a throwaway temp file first.
10
- function freshModules(metricsLogPath) {
11
- process.env.METRICS_LOG_PATH = metricsLogPath;
12
- delete require.cache[require.resolve('../metrics')];
13
- delete require.cache[require.resolve('../router')];
14
- const metrics = require('../metrics');
15
- const router = require('../router');
16
- return { metrics, router };
17
- }
18
-
19
- function tempLogPath() {
20
- return path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'router-test-')), 'metrics.jsonl');
21
- }
22
-
23
- test('isVirtualModel only matches the router: prefix', () => {
24
- const { router } = freshModules(tempLogPath());
25
- assert.equal(router.isVirtualModel('router:fast-cheap'), true);
26
- assert.equal(router.isVirtualModel('gpt-4o-mini'), false);
27
- assert.equal(router.isVirtualModel('claude-sonnet-4-5-20250929'), false);
28
- assert.equal(router.isVirtualModel(undefined), false);
29
- });
30
-
31
- test('pickCandidate returns an error for an unknown tier', async () => {
32
- const { router } = freshModules(tempLogPath());
33
- const decision = await router.pickCandidate('router:does-not-exist');
34
- assert.ok(decision.error);
35
- });
36
-
37
- test('pickCandidate picks the cheaper candidate when both are healthy (no history)', async () => {
38
- const { router } = freshModules(tempLogPath());
39
- // gpt-4o-mini ($0.15/$0.60 per 1M) is cheaper than claude-haiku-4-5
40
- // ($0.80/$4.00 per 1M) at the fixed comparison token counts - see
41
- // router.js's COMPARISON_INPUT_TOKENS/OUTPUT_TOKENS.
42
- const decision = await router.pickCandidate('router:fast-cheap');
43
- assert.equal(decision.provider, 'openai');
44
- assert.equal(decision.model, 'gpt-4o-mini');
45
- assert.equal(decision.reason.allUnhealthy, false);
46
- });
47
-
48
- test('pickCandidate exposes the full ranked candidate list, in the same order as the top pick', async () => {
49
- const { router } = freshModules(tempLogPath());
50
- const decision = await router.pickCandidate('router:fast-cheap');
51
- assert.deepEqual(decision.rankedCandidates, [
52
- { provider: 'openai', model: 'gpt-4o-mini' },
53
- { provider: 'anthropic', model: 'claude-haiku-4-5-20251001' }
54
- ]);
55
- // The top pick is always the first entry - server.js's failover loop
56
- // relies on this to know which candidate it started with.
57
- assert.equal(decision.rankedCandidates[0].provider, decision.provider);
58
- assert.equal(decision.rankedCandidates[0].model, decision.model);
59
- });
60
-
61
- test('pickCandidate skips a candidate whose recent error rate is too high, even if cheaper', async () => {
62
- const logPath = tempLogPath();
63
- const { metrics, router } = freshModules(logPath);
64
-
65
- // Make openai (the cheaper candidate in router:fast-cheap) look
66
- // unhealthy: mostly errors in its recent history.
67
- for (let i = 0; i < 10; i++) {
68
- metrics.record({ provider: 'openai', error: 'simulated failure' });
69
- }
70
- metrics.record({ provider: 'anthropic', latency_ms: 500 });
71
-
72
- // Metrics writes go through a stream; give it a tick to flush before
73
- // reading the file back.
74
- await new Promise((resolve) => setTimeout(resolve, 50));
75
-
76
- const decision = await router.pickCandidate('router:fast-cheap');
77
- assert.equal(decision.provider, 'anthropic');
78
- assert.equal(decision.model, 'claude-haiku-4-5-20251001');
79
- assert.equal(decision.reason.allUnhealthy, false);
80
- });
81
-
82
- test('pickCandidate still returns a candidate when every option is unhealthy', async () => {
83
- const logPath = tempLogPath();
84
- const { metrics, router } = freshModules(logPath);
85
-
86
- for (let i = 0; i < 10; i++) {
87
- metrics.record({ provider: 'openai', error: 'simulated failure' });
88
- metrics.record({ provider: 'anthropic', error: 'simulated failure' });
89
- }
90
- await new Promise((resolve) => setTimeout(resolve, 50));
91
-
92
- const decision = await router.pickCandidate('router:fast-cheap');
93
- assert.ok(decision.provider);
94
- assert.equal(decision.reason.allUnhealthy, true);
95
- });
96
-
97
- test('pickCandidate reports which strategy it used', async () => {
98
- const { router } = freshModules(tempLogPath());
99
- const decision = await router.pickCandidate('router:fast-cheap');
100
- assert.equal(decision.reason.strategy, 'cost'); // default, no ROUTER_STRATEGY set
101
- });
102
-
103
- async function flush() {
104
- await new Promise((resolve) => setTimeout(resolve, 50));
105
- }
106
-
107
- test('ROUTER_STRATEGY=latency picks the fastest healthy candidate even when it costs more', async () => {
108
- const logPath = tempLogPath();
109
- process.env.ROUTER_STRATEGY = 'latency';
110
- const { metrics, router } = freshModules(logPath);
111
-
112
- // gpt-4o-mini is the cheaper candidate in router:fast-cheap, but make
113
- // it noticeably slower than claude-haiku here.
114
- for (let i = 0; i < 5; i++) metrics.record({ provider: 'openai', latency_ms: 2000 });
115
- for (let i = 0; i < 5; i++) metrics.record({ provider: 'anthropic', latency_ms: 100 });
116
- await flush();
117
-
118
- const decision = await router.pickCandidate('router:fast-cheap');
119
- assert.equal(decision.provider, 'anthropic');
120
- assert.equal(decision.model, 'claude-haiku-4-5-20251001');
121
- assert.equal(decision.reason.strategy, 'latency');
122
-
123
- delete process.env.ROUTER_STRATEGY;
124
- });
125
-
126
- test('ROUTER_STRATEGY=latency falls back to cost as a tiebreaker when latency is equal (e.g. both unknown)', async () => {
127
- process.env.ROUTER_STRATEGY = 'latency';
128
- const { router } = freshModules(tempLogPath()); // no recorded latencies at all - both candidates are unknown
129
-
130
- const decision = await router.pickCandidate('router:fast-cheap');
131
- assert.equal(decision.model, 'gpt-4o-mini'); // the cheaper of the two, same as the default cost strategy would pick
132
-
133
- delete process.env.ROUTER_STRATEGY;
134
- });
135
-
136
- test('ROUTER_STRATEGY=latency-guarded-cost excludes a candidate far slower than the fastest known one, even if cheaper', async () => {
137
- const logPath = tempLogPath();
138
- process.env.ROUTER_STRATEGY = 'latency-guarded-cost';
139
- const { metrics, router } = freshModules(logPath);
140
-
141
- // openai/gpt-4o-mini is the cheaper candidate, but 3000ms is far more
142
- // than 3x (the default guard multiplier) slower than anthropic's
143
- // 100ms - it should get excluded by the guard despite being cheaper.
144
- for (let i = 0; i < 5; i++) metrics.record({ provider: 'openai', latency_ms: 3000 });
145
- for (let i = 0; i < 5; i++) metrics.record({ provider: 'anthropic', latency_ms: 100 });
146
- await flush();
147
-
148
- const decision = await router.pickCandidate('router:fast-cheap');
149
- assert.equal(decision.provider, 'anthropic');
150
- assert.equal(decision.reason.strategy, 'latency-guarded-cost');
151
- assert.equal(decision.reason.latencyGuardExcludedACandidate, true);
152
-
153
- delete process.env.ROUTER_STRATEGY;
154
- });
155
-
156
- test('ROUTER_STRATEGY=latency-guarded-cost keeps a candidate that is only modestly slower, and still picks by cost', async () => {
157
- const logPath = tempLogPath();
158
- process.env.ROUTER_STRATEGY = 'latency-guarded-cost';
159
- const { metrics, router } = freshModules(logPath);
160
-
161
- // 150ms is only 1.5x anthropic's 100ms - comfortably inside the
162
- // default 3x guard multiplier, so openai stays in the pool and wins
163
- // on cost as usual.
164
- for (let i = 0; i < 5; i++) metrics.record({ provider: 'openai', latency_ms: 150 });
165
- for (let i = 0; i < 5; i++) metrics.record({ provider: 'anthropic', latency_ms: 100 });
166
- await flush();
167
-
168
- const decision = await router.pickCandidate('router:fast-cheap');
169
- assert.equal(decision.model, 'gpt-4o-mini');
170
- assert.equal(decision.reason.latencyGuardExcludedACandidate, false);
171
-
172
- delete process.env.ROUTER_STRATEGY;
173
- });
174
-
175
- test('ROUTER_STRATEGY=latency-guarded-cost degrades to plain cost when there is no latency data yet', async () => {
176
- process.env.ROUTER_STRATEGY = 'latency-guarded-cost';
177
- const { router } = freshModules(tempLogPath()); // no history at all
178
-
179
- const decision = await router.pickCandidate('router:fast-cheap');
180
- assert.equal(decision.model, 'gpt-4o-mini'); // nothing to guard against yet
181
- assert.equal(decision.reason.latencyGuardExcludedACandidate, false);
182
-
183
- delete process.env.ROUTER_STRATEGY;
184
- });
185
-
186
- test('an unrecognized ROUTER_STRATEGY value falls back to "cost" rather than erroring', async () => {
187
- process.env.ROUTER_STRATEGY = 'fastest-vibes';
188
- const { router } = freshModules(tempLogPath());
189
-
190
- const decision = await router.pickCandidate('router:fast-cheap');
191
- assert.equal(decision.reason.strategy, 'cost');
192
- assert.equal(decision.model, 'gpt-4o-mini');
193
-
194
- delete process.env.ROUTER_STRATEGY;
195
- });
@@ -1,167 +0,0 @@
1
- // Exercises the real storage/lookup machinery (list writes, trimming,
2
- // cosine-similarity scoring) against an actual Redis instance, not a
3
- // mock - this test file spins up its own throwaway redis-server for
4
- // the duration of the run so `npm test` stays self-contained. It never
5
- // calls a real embedding API: a deterministic fake embedder is injected
6
- // via the `embeddings` option both findMatch() and store() accept, so
7
- // the similarity math is genuinely exercised without needing
8
- // OPENAI_API_KEY or network access.
9
-
10
- const { test, before, after } = require('node:test');
11
- const assert = require('node:assert/strict');
12
- const { spawn } = require('child_process');
13
- const net = require('net');
14
-
15
- let redisProcess;
16
- let redisClient;
17
- let semanticCache;
18
- let cache;
19
-
20
- function getFreePort() {
21
- return new Promise((resolve, reject) => {
22
- const srv = net.createServer();
23
- srv.listen(0, () => {
24
- const { port } = srv.address();
25
- srv.close(() => resolve(port));
26
- });
27
- srv.on('error', reject);
28
- });
29
- }
30
-
31
- function waitForRedisReady(proc) {
32
- return new Promise((resolve, reject) => {
33
- const timeout = setTimeout(() => reject(new Error('redis-server did not become ready in time')), 10_000);
34
- proc.stdout.on('data', (chunk) => {
35
- if (chunk.toString().includes('Ready to accept connections')) {
36
- clearTimeout(timeout);
37
- resolve();
38
- }
39
- });
40
- proc.on('error', reject);
41
- });
42
- }
43
-
44
- // A small fixed-dimension deterministic "embedding": each word maps to
45
- // a pseudo-random but stable vector slot via a simple hash, so two
46
- // texts sharing words end up with similar vectors and unrelated texts
47
- // don't - enough to exercise cosine-similarity thresholding
48
- // meaningfully without a real embedding model.
49
- function fakeEmbed(text) {
50
- const dims = 32;
51
- const vec = new Array(dims).fill(0);
52
- for (const word of text.toLowerCase().split(/\W+/).filter(Boolean)) {
53
- let h = 0;
54
- for (let i = 0; i < word.length; i++) h = (h * 31 + word.charCodeAt(i)) >>> 0;
55
- vec[h % dims] += 1;
56
- }
57
- return Promise.resolve(vec);
58
- }
59
-
60
- const fakeEmbeddings = { isEnabled: () => true, embed: fakeEmbed };
61
-
62
- before(async () => {
63
- const port = await getFreePort();
64
- process.env.REDIS_URL = `redis://127.0.0.1:${port}`;
65
- redisProcess = spawn('redis-server', ['--port', String(port), '--save', '', '--appendonly', 'no'], {
66
- stdio: ['ignore', 'pipe', 'pipe']
67
- });
68
- await waitForRedisReady(redisProcess);
69
-
70
- redisClient = require('../redisClient');
71
- await redisClient.ready;
72
- semanticCache = require('../semanticCache');
73
- cache = require('../cache');
74
- });
75
-
76
- after(async () => {
77
- try { await redisClient.client.quit(); } catch { /* already closed */ }
78
- if (redisProcess) redisProcess.kill();
79
- });
80
-
81
- test('isEnabled() is true once Redis is connected and embeddings report enabled', () => {
82
- assert.equal(semanticCache.isEnabled(fakeEmbeddings), true);
83
- });
84
-
85
- test('isEnabled() is false when embeddings are disabled, even with Redis connected', () => {
86
- assert.equal(semanticCache.isEnabled({ isEnabled: () => false }), false);
87
- });
88
-
89
- test('the exact-match cache (cache.js) still works over the same shared connection', async () => {
90
- const payload = { model: 'gpt-4o-mini', messages: [{ role: 'user', content: 'shared connection check' }] };
91
- assert.equal(await cache.get(payload), null);
92
- await cache.set(payload, { provider: 'openai', model: 'gpt-4o-mini', content: 'ok' });
93
- const hit = await cache.get(payload);
94
- assert.equal(hit.content, 'ok');
95
- });
96
-
97
- test('findMatch() returns null when nothing has been stored yet', async () => {
98
- const payload = { model: 'router-test-model-empty', messages: [{ role: 'user', content: 'is anyone there' }] };
99
- const result = await semanticCache.findMatch(payload, { embeddings: fakeEmbeddings });
100
- assert.equal(result, null);
101
- });
102
-
103
- test('store() then findMatch() with the identical prompt finds a match (similarity ~1)', async () => {
104
- const model = 'router-test-model-identical';
105
- const payload = { model, messages: [{ role: 'user', content: 'how do I reset my password' }] };
106
- const entry = { provider: 'openai', model, content: 'Go to Settings > Security > Reset password.' };
107
-
108
- const stored = await semanticCache.store(payload, entry, { embeddings: fakeEmbeddings });
109
- assert.equal(stored, true);
110
-
111
- const match = await semanticCache.findMatch(payload, { embeddings: fakeEmbeddings });
112
- assert.ok(match, 'expected a match for the identical prompt');
113
- assert.equal(match.entry.content, entry.content);
114
- assert.ok(match.similarity > 0.99, `expected near-1 similarity, got ${match.similarity}`);
115
- });
116
-
117
- test('findMatch() does not match an unrelated prompt under the same model', async () => {
118
- const model = 'router-test-model-unrelated';
119
- const stored = { model, messages: [{ role: 'user', content: 'how do I reset my password' }] };
120
- await semanticCache.store(stored, { provider: 'openai', model, content: 'reset password steps' }, { embeddings: fakeEmbeddings });
121
-
122
- const unrelated = { model, messages: [{ role: 'user', content: 'what is the weather in Tokyo tomorrow' }] };
123
- const match = await semanticCache.findMatch(unrelated, { embeddings: fakeEmbeddings, threshold: 0.93 });
124
- assert.equal(match, null);
125
- });
126
-
127
- test('findMatch()/store() skip tool-calling requests entirely (never cached, never matched)', async () => {
128
- const model = 'router-test-model-tools';
129
- const payload = {
130
- model,
131
- messages: [{ role: 'user', content: 'call the tool' }],
132
- tools: [{ type: 'function', function: { name: 'do_thing' } }]
133
- };
134
- const stored = await semanticCache.store(payload, { provider: 'openai', model, content: 'x' }, { embeddings: fakeEmbeddings });
135
- assert.equal(stored, false);
136
-
137
- const match = await semanticCache.findMatch(payload, { embeddings: fakeEmbeddings });
138
- assert.equal(match, null);
139
- });
140
-
141
- test('per-model list is trimmed to SEMANTIC_CACHE_MAX_CANDIDATES', async () => {
142
- const originalMax = process.env.SEMANTIC_CACHE_MAX_CANDIDATES;
143
- process.env.SEMANTIC_CACHE_MAX_CANDIDATES = '3';
144
- delete require.cache[require.resolve('../semanticCache')];
145
- const scopedSemanticCache = require('../semanticCache');
146
-
147
- const model = 'router-test-model-trim';
148
- for (let i = 0; i < 5; i++) {
149
- await scopedSemanticCache.store(
150
- { model, messages: [{ role: 'user', content: `distinct prompt number ${i}` }] },
151
- { provider: 'openai', model, content: `answer ${i}` },
152
- { embeddings: fakeEmbeddings }
153
- );
154
- }
155
-
156
- const length = await redisClient.client.lLen(`SEMANTIC_LIST:${model}`);
157
- assert.equal(length, 3);
158
-
159
- if (originalMax === undefined) delete process.env.SEMANTIC_CACHE_MAX_CANDIDATES;
160
- else process.env.SEMANTIC_CACHE_MAX_CANDIDATES = originalMax;
161
- });
162
-
163
- test('cosineSimilarity() is 1 for identical vectors and 0 for orthogonal ones', () => {
164
- assert.equal(semanticCache.cosineSimilarity([1, 0], [1, 0]), 1);
165
- assert.equal(semanticCache.cosineSimilarity([1, 0], [0, 1]), 0);
166
- assert.equal(semanticCache.cosineSimilarity([1, 0], [0, 0]), 0); // zero vector is defined as no similarity, not NaN
167
- });