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.
@@ -0,0 +1,282 @@
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
+ function freshMetrics() {
8
+ const logPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'metrics-test-')), 'metrics.jsonl');
9
+ process.env.METRICS_LOG_PATH = logPath;
10
+ delete require.cache[require.resolve('../metrics')];
11
+ return require('../metrics');
12
+ }
13
+
14
+ async function flush() {
15
+ await new Promise((resolve) => setTimeout(resolve, 50));
16
+ }
17
+
18
+ test('readRecent returns [] when no log file exists yet', async () => {
19
+ const metrics = freshMetrics();
20
+ const rows = await metrics.readRecent();
21
+ assert.deepEqual(rows, []);
22
+ });
23
+
24
+ test('record() writes a line that readRecent() can read back, with a timestamp added', async () => {
25
+ const metrics = freshMetrics();
26
+ metrics.record({ provider: 'openai', latency_ms: 120, cost_usd: 0.001 });
27
+ await flush();
28
+
29
+ const rows = await metrics.readRecent();
30
+ assert.equal(rows.length, 1);
31
+ assert.equal(rows[0].provider, 'openai');
32
+ assert.ok(rows[0].timestamp, 'expected record() to stamp a timestamp');
33
+ });
34
+
35
+ test('a malformed line in the log is skipped, not fatal', async () => {
36
+ const metrics = freshMetrics();
37
+ metrics.record({ provider: 'openai', latency_ms: 10 });
38
+ await flush();
39
+ fs.appendFileSync(metrics.currentLogPath(), 'not valid json\n');
40
+ metrics.record({ provider: 'anthropic', latency_ms: 20 });
41
+ await flush();
42
+
43
+ const rows = await metrics.readRecent();
44
+ assert.equal(rows.length, 2);
45
+ });
46
+
47
+ test('currentLogPath() names a per-UTC-day file, and record() actually writes there', async () => {
48
+ const metrics = freshMetrics();
49
+ const today = new Date().toISOString().slice(0, 10);
50
+ assert.match(metrics.currentLogPath(), new RegExp(`metrics-${today}\\.jsonl$`));
51
+
52
+ metrics.record({ provider: 'openai' });
53
+ await flush();
54
+ assert.ok(fs.existsSync(metrics.currentLogPath()), "record() should write to today's rotated file");
55
+ });
56
+
57
+ test('listLogFiles() finds only files matching the rotation naming pattern, sorted ascending by date', async () => {
58
+ const metrics = freshMetrics();
59
+ fs.writeFileSync(path.join(metrics.DATA_DIR, 'metrics-2026-08-10.jsonl'), '');
60
+ fs.writeFileSync(path.join(metrics.DATA_DIR, 'metrics-2026-08-01.jsonl'), '');
61
+ fs.writeFileSync(path.join(metrics.DATA_DIR, 'not-a-metrics-file.txt'), '');
62
+
63
+ const files = await metrics.listLogFiles();
64
+ assert.deepEqual(files.map((f) => f.date), ['2026-08-01', '2026-08-10']);
65
+ });
66
+
67
+ test('readRecent() aggregates across multiple real day-files, newest data included first', async () => {
68
+ const metrics = freshMetrics();
69
+ fs.writeFileSync(
70
+ path.join(metrics.DATA_DIR, 'metrics-2026-08-01.jsonl'),
71
+ JSON.stringify({ timestamp: '2026-08-01T00:00:00.000Z', provider: 'anthropic', tag: 'old' }) + '\n'
72
+ );
73
+ fs.writeFileSync(
74
+ path.join(metrics.DATA_DIR, 'metrics-2026-08-02.jsonl'),
75
+ JSON.stringify({ timestamp: '2026-08-02T00:00:00.000Z', provider: 'openai', tag: 'newer' }) + '\n'
76
+ );
77
+
78
+ const rows = await metrics.readRecent(10);
79
+ assert.equal(rows.length, 2);
80
+ assert.deepEqual(rows.map((r) => r.tag), ['old', 'newer']); // chronological order preserved
81
+ });
82
+
83
+ test('readRecent(limit) stops opening older files once enough rows are collected', async () => {
84
+ const metrics = freshMetrics();
85
+ fs.writeFileSync(
86
+ path.join(metrics.DATA_DIR, 'metrics-2026-08-01.jsonl'),
87
+ JSON.stringify({ timestamp: '2026-08-01T00:00:00.000Z', tag: 'should-not-be-needed' }) + '\n'
88
+ );
89
+ fs.writeFileSync(
90
+ path.join(metrics.DATA_DIR, 'metrics-2026-08-02.jsonl'),
91
+ Array.from({ length: 5 }, (_, i) => JSON.stringify({ timestamp: '2026-08-02T00:00:00.000Z', tag: `row-${i}` })).join('\n') + '\n'
92
+ );
93
+
94
+ const rows = await metrics.readRecent(5);
95
+ assert.equal(rows.length, 5);
96
+ assert.ok(rows.every((r) => r.tag.startsWith('row-')), 'the older file should not have been needed to satisfy limit:5');
97
+ });
98
+
99
+ test('pruneOlderThan() deletes only day-files strictly older than the cutoff, and is never called by anything else in this module', async () => {
100
+ const metrics = freshMetrics();
101
+ const oldPath = path.join(metrics.DATA_DIR, 'metrics-2026-01-01.jsonl');
102
+ const recentPath = metrics.currentLogPath();
103
+ fs.writeFileSync(oldPath, '{}\n');
104
+ metrics.record({ provider: 'openai' });
105
+ await flush();
106
+
107
+ const deleted = await metrics.pruneOlderThan(30);
108
+ assert.deepEqual(deleted, [oldPath]);
109
+ assert.equal(fs.existsSync(oldPath), false);
110
+ assert.equal(fs.existsSync(recentPath), true, "pruneOlderThan should never touch today's file");
111
+ });
112
+
113
+ test('providerStats() computes error rate and average latency per provider', async () => {
114
+ const metrics = freshMetrics();
115
+ metrics.record({ provider: 'openai', latency_ms: 100 });
116
+ metrics.record({ provider: 'openai', latency_ms: 200 });
117
+ metrics.record({ provider: 'openai', error: 'boom' });
118
+ await flush();
119
+
120
+ const stats = await metrics.providerStats();
121
+ assert.equal(stats.openai.sampleSize, 3);
122
+ assert.equal(stats.openai.errorRate, 1 / 3);
123
+ assert.equal(stats.openai.avgLatencyMs, 150); // average of the two non-error latencies
124
+ });
125
+
126
+ test('providerStats() only considers the most recent windowSize entries', async () => {
127
+ const metrics = freshMetrics();
128
+ for (let i = 0; i < 5; i++) metrics.record({ provider: 'openai', error: 'boom' });
129
+ for (let i = 0; i < 5; i++) metrics.record({ provider: 'openai', latency_ms: 50 });
130
+ await flush();
131
+
132
+ const stats = await metrics.providerStats(5); // window covers only the second batch
133
+ assert.equal(stats.openai.errorRate, 0);
134
+ assert.equal(stats.openai.avgLatencyMs, 50);
135
+ });
136
+
137
+ test('classifyErrorType() reads Anthropic\'s own {error:{type}} shape (SDK message = "<status> <json>")', () => {
138
+ const metrics = freshMetrics();
139
+ const msg = '401 {"type":"error","error":{"type":"authentication_error","message":"API key is invalid."},"request_id":null}';
140
+ assert.equal(metrics.classifyErrorType(msg), 'authentication_error');
141
+ });
142
+
143
+ test('classifyErrorType() reads OpenAI\'s own {error:{type,code}} shape', () => {
144
+ const metrics = freshMetrics();
145
+ const msg = '429 {"error":{"message":"You exceeded your current quota.","type":"insufficient_quota","param":null,"code":"insufficient_quota"}}';
146
+ assert.equal(metrics.classifyErrorType(msg), 'insufficient_quota');
147
+ });
148
+
149
+ test('classifyErrorType() reads an invalid-key error identified only by code, not type', () => {
150
+ const metrics = freshMetrics();
151
+ const msg = '401 {"error":{"message":"Incorrect API key provided.","type":"invalid_request_error","param":null,"code":"invalid_api_key"}}';
152
+ assert.equal(metrics.classifyErrorType(msg), 'authentication_error');
153
+ });
154
+
155
+ test('classifyErrorType() falls back to keyword matching when the message has no parseable JSON body', () => {
156
+ const metrics = freshMetrics();
157
+ assert.equal(metrics.classifyErrorType('connect ECONNREFUSED - the key looks invalid'), 'authentication_error');
158
+ assert.equal(metrics.classifyErrorType('insufficient funds on this account'), 'insufficient_quota');
159
+ assert.equal(metrics.classifyErrorType('429 rate limit exceeded, slow down'), 'rate_limit_error');
160
+ assert.equal(metrics.classifyErrorType('connect ECONNREFUSED 127.0.0.1:4000'), 'unknown');
161
+ });
162
+
163
+ test('classifyErrorType() never throws on an empty/undefined message', () => {
164
+ const metrics = freshMetrics();
165
+ assert.equal(metrics.classifyErrorType(''), 'unknown');
166
+ assert.equal(metrics.classifyErrorType(undefined), 'unknown');
167
+ });
168
+
169
+ test('providerStats() reports the MOST RECENT error\'s classified type, not an earlier one still in the window', async () => {
170
+ const metrics = freshMetrics();
171
+ metrics.record({
172
+ provider: 'anthropic',
173
+ error: '429 {"type":"error","error":{"type":"rate_limit_error"}}',
174
+ error_type: 'rate_limit_error'
175
+ });
176
+ metrics.record({ provider: 'anthropic', latency_ms: 500 }); // recovered in between
177
+ metrics.record({
178
+ provider: 'anthropic',
179
+ error: '401 {"type":"error","error":{"type":"authentication_error"}}',
180
+ error_type: 'authentication_error'
181
+ });
182
+ await flush();
183
+
184
+ const stats = await metrics.providerStats();
185
+ assert.equal(stats.anthropic.lastErrorType, 'authentication_error');
186
+ assert.ok(stats.anthropic.lastErrorAt, 'expected a timestamp on the last error');
187
+ });
188
+
189
+ test('providerStats() classifies on the fly for an older record written before error_type existed', async () => {
190
+ const metrics = freshMetrics();
191
+ // No error_type field at all - simulates a log line from before this
192
+ // feature existed, still stored on disk after an upgrade.
193
+ metrics.record({ provider: 'openai', error: '401 {"error":{"type":"authentication_error"}}' });
194
+ await flush();
195
+
196
+ const stats = await metrics.providerStats();
197
+ assert.equal(stats.openai.lastErrorType, 'authentication_error');
198
+ });
199
+
200
+ test('providerStats() reports lastErrorType as null for a provider with no errors at all', async () => {
201
+ const metrics = freshMetrics();
202
+ metrics.record({ provider: 'openai', latency_ms: 100 });
203
+ await flush();
204
+
205
+ const stats = await metrics.providerStats();
206
+ assert.equal(stats.openai.lastErrorType, null);
207
+ assert.equal(stats.openai.lastErrorAt, null);
208
+ });
209
+
210
+ function isoDaysAgo(days) {
211
+ return new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
212
+ }
213
+
214
+ function appendRaw(metrics, entry) {
215
+ // Deliberately appended to TODAY's physical file regardless of the
216
+ // entry's own (possibly historical) timestamp - this test is about
217
+ // rangeSummary()'s PER-ROW timestamp filtering, not file rotation
218
+ // (that has its own dedicated tests above). Today's file always
219
+ // passes rangeSummary()'s file-level date filter for any days >= 0,
220
+ // so the row-level filter is what's actually being exercised here.
221
+ fs.appendFileSync(metrics.currentLogPath(), JSON.stringify(entry) + '\n');
222
+ }
223
+
224
+ test('rangeSummary() buckets by calendar day and excludes rows outside the window', async () => {
225
+ const metrics = freshMetrics();
226
+ appendRaw(metrics, { timestamp: isoDaysAgo(0), provider: 'openai', cost_usd: 0.01, cache_hit: false });
227
+ appendRaw(metrics, { timestamp: isoDaysAgo(0), provider: 'openai', cost_usd: 0.02, cache_hit: true, cache_type: 'exact' });
228
+ appendRaw(metrics, { timestamp: isoDaysAgo(1), provider: 'anthropic', cost_usd: 0.05, cache_hit: true, cache_type: 'semantic' });
229
+ appendRaw(metrics, { timestamp: isoDaysAgo(30), provider: 'anthropic', cost_usd: 99, cache_hit: false }); // outside a 14-day window
230
+
231
+ const summary = await metrics.rangeSummary(14);
232
+ assert.equal(summary.sample_size, 3);
233
+ assert.equal(summary.total_cost_usd, 0.08);
234
+ assert.equal(summary.daily.length, 2); // two distinct calendar days, the 30-day-old row excluded
235
+ const todayBucket = summary.daily[summary.daily.length - 1];
236
+ assert.equal(todayBucket.requests, 2);
237
+ assert.equal(todayBucket.exact_hits, 1);
238
+ assert.equal(todayBucket.misses, 1);
239
+ });
240
+
241
+ test('rangeSummary() reports exact/semantic/combined hit rate and error rate separately', async () => {
242
+ const metrics = freshMetrics();
243
+ metrics.record({ provider: 'openai', cost_usd: 0.01, cache_hit: false });
244
+ metrics.record({ provider: 'openai', cost_usd: 0, cache_hit: true, cache_type: 'exact' });
245
+ metrics.record({ provider: 'openai', cost_usd: 0, cache_hit: true, cache_type: 'semantic' });
246
+ metrics.record({ provider: 'openai', error: 'boom' });
247
+ await flush();
248
+
249
+ const summary = await metrics.rangeSummary(14);
250
+ assert.equal(summary.sample_size, 4);
251
+ assert.equal(summary.cache_hit_rate.exact, 0.25);
252
+ assert.equal(summary.cache_hit_rate.semantic, 0.25);
253
+ assert.equal(summary.cache_hit_rate.combined, 0.5);
254
+ assert.equal(summary.error_rate, 0.25);
255
+ });
256
+
257
+ test('rangeSummary() computes per-provider cost, requests, error rate and avg latency', async () => {
258
+ const metrics = freshMetrics();
259
+ metrics.record({ provider: 'openai', cost_usd: 0.01, latency_ms: 100 });
260
+ metrics.record({ provider: 'openai', cost_usd: 0.02, latency_ms: 300 });
261
+ metrics.record({ provider: 'openai', error: 'boom' });
262
+ metrics.record({ provider: 'anthropic', cost_usd: 0.5, latency_ms: 200 });
263
+ await flush();
264
+
265
+ const summary = await metrics.rangeSummary(14);
266
+ assert.equal(summary.by_provider.openai.requests, 3);
267
+ assert.equal(summary.by_provider.openai.cost_usd, 0.03);
268
+ assert.equal(summary.by_provider.openai.errorRate, 1 / 3);
269
+ assert.equal(summary.by_provider.openai.avgLatencyMs, 200); // average of the two non-error latencies
270
+ assert.equal(summary.by_provider.anthropic.requests, 1);
271
+ assert.equal(summary.by_provider.anthropic.cost_usd, 0.5);
272
+ });
273
+
274
+ test('rangeSummary() on an empty log returns zeroed rates, not NaN or a crash', async () => {
275
+ const metrics = freshMetrics();
276
+ const summary = await metrics.rangeSummary(14);
277
+ assert.equal(summary.sample_size, 0);
278
+ assert.equal(summary.cache_hit_rate.combined, 0);
279
+ assert.equal(summary.error_rate, 0);
280
+ assert.deepEqual(summary.daily, []);
281
+ assert.deepEqual(summary.by_provider, {});
282
+ });
@@ -0,0 +1,195 @@
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
+ });
@@ -0,0 +1,167 @@
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
+ });