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/metrics.js ADDED
@@ -0,0 +1,556 @@
1
+ // model-router/metrics.js
2
+ //
3
+ // A shared record of what actually happened on every request: which
4
+ // provider handled it, how long it took, what it cost, whether it was
5
+ // a cache hit, and whether it errored. Two things depend on this data
6
+ // existing, so it's built once, here, rather than twice:
7
+ //
8
+ // 1. Routing (router.js) needs rolling latency/error-rate per
9
+ // provider to make a "cheapest CAPABLE provider" decision - a
10
+ // static price table alone can't tell you a provider is currently
11
+ // slow or failing.
12
+ // 2. The cost dashboard needs historical data to show - there is
13
+ // nothing to dashboard without a log.
14
+ //
15
+ // Storage is local, append-only JSONL, not a database - that keeps the
16
+ // self-hosted/lightweight positioning honest (no new infrastructure to
17
+ // run) while still being real persistence: every line is a complete,
18
+ // independent JSON record, so a crash mid-write loses at most the one
19
+ // in-flight line, and any tool that can read lines of JSON (jq, a
20
+ // script) can consume it directly.
21
+ //
22
+ // ROTATION: one file per UTC calendar day (metrics-YYYY-MM-DD.jsonl),
23
+ // not one file forever. This used to be a real, documented gap - a
24
+ // single ever-growing file that every read (a dashboard load, a /stats
25
+ // call, a routing-health check) re-read and re-parsed in FULL,
26
+ // regardless of how much data the caller actually needed. Splitting by
27
+ // day means readRecent() and rangeSummary() only open the files that
28
+ // could actually contain what they're looking for - a 14-day dashboard
29
+ // query reads at most 15 files, not the service's entire history.
30
+ // Old files are NOT deleted automatically - see pruneOlderThan() for
31
+ // the explicit, opt-in cleanup an operator can run; silently deleting
32
+ // someone's cost history without being asked is a worse default than
33
+ // disk slowly filling up, and this module doesn't get to make that
34
+ // retention call on its own.
35
+
36
+ const fs = require('fs');
37
+ const path = require('path');
38
+ const readline = require('readline');
39
+ const { Pool } = require('pg');
40
+
41
+ // Postgres-backed persistence - OPT-IN, not a replacement. The JSONL
42
+ // file storage above/below stays the default for exactly the reason
43
+ // its own original comment gives: self-hosted/lightweight, zero new
44
+ // infrastructure required to run this router standalone in some other
45
+ // app. But the EMBEDDED deployment inside MemoCode specifically already
46
+ // has a real Postgres database (memocode-db, provisioned for its own
47
+ // user-account/library data regardless of this router) - reusing that
48
+ // costs nothing new (no extra service, no extra bill, no extra account)
49
+ // and, unlike the router's own container filesystem, genuinely survives
50
+ // a restart/redeploy. DATABASE_URL is Render's own standard convention
51
+ // for injecting a database's connection string (matches how
52
+ // 000_backend/db.mjs reads the exact same variable for the exact same
53
+ // reason) - set it and every function below transparently reads/writes
54
+ // Postgres instead of local files; leave it unset and nothing here
55
+ // changes at all.
56
+ function usingPostgres() {
57
+ return Boolean(process.env.DATABASE_URL || process.env.MEMOCODE_ROUTER_DATABASE_URL);
58
+ }
59
+
60
+ let pgPool = null;
61
+ function getPool() {
62
+ if (!pgPool) {
63
+ const connectionString = process.env.MEMOCODE_ROUTER_DATABASE_URL || process.env.DATABASE_URL;
64
+ pgPool = new Pool({
65
+ connectionString,
66
+ // Same rule db.mjs already uses: a real hosted Postgres (Render's
67
+ // managed instance) needs SSL; a local one (dev, this module's
68
+ // own tests) doesn't and would just fail the handshake if asked.
69
+ ssl: connectionString && !/localhost|127\.0\.0\.1/.test(connectionString) ? { rejectUnauthorized: false } : false
70
+ });
71
+ }
72
+ return pgPool;
73
+ }
74
+
75
+ // Idempotent - safe to call on every getPool() use (CREATE TABLE/INDEX
76
+ // IF NOT EXISTS), so a fresh deployment self-provisions its own schema
77
+ // on first write with no separate migration step to remember to run.
78
+ let schemaReady = null;
79
+ async function ensureSchema() {
80
+ if (!schemaReady) {
81
+ schemaReady = getPool().query(`
82
+ CREATE TABLE IF NOT EXISTS router_metrics (
83
+ id BIGSERIAL PRIMARY KEY,
84
+ ts TIMESTAMPTZ NOT NULL DEFAULT now(),
85
+ provider TEXT,
86
+ model TEXT,
87
+ requested_model TEXT,
88
+ cache_hit BOOLEAN,
89
+ cache_type TEXT,
90
+ latency_ms INTEGER,
91
+ cost_usd DOUBLE PRECISION,
92
+ error TEXT,
93
+ error_type TEXT
94
+ );
95
+ CREATE INDEX IF NOT EXISTS router_metrics_ts_idx ON router_metrics (ts DESC);
96
+ CREATE INDEX IF NOT EXISTS router_metrics_provider_ts_idx ON router_metrics (provider, ts DESC);
97
+ `);
98
+ }
99
+ return schemaReady;
100
+ }
101
+
102
+ // Maps one Postgres row back to the exact same shape record() writes to
103
+ // a JSONL line - so every function below this point (readRecent,
104
+ // providerStats, rangeSummary) can share its existing row-aggregation
105
+ // logic UNCHANGED regardless of which backend actually supplied the
106
+ // rows. Only the row-fetching prelude differs between the two backends;
107
+ // nothing downstream needs to know or care which one ran.
108
+ function rowFromPg(dbRow) {
109
+ return {
110
+ timestamp: dbRow.ts.toISOString(),
111
+ provider: dbRow.provider || undefined,
112
+ model: dbRow.model || undefined,
113
+ requested_model: dbRow.requested_model || undefined,
114
+ cache_hit: dbRow.cache_hit === null ? undefined : dbRow.cache_hit,
115
+ cache_type: dbRow.cache_type || undefined,
116
+ latency_ms: dbRow.latency_ms === null ? undefined : dbRow.latency_ms,
117
+ cost_usd: dbRow.cost_usd === null ? undefined : dbRow.cost_usd,
118
+ error: dbRow.error || undefined,
119
+ error_type: dbRow.error_type || undefined
120
+ };
121
+ }
122
+
123
+ async function recordToPostgres(entry) {
124
+ try {
125
+ await ensureSchema();
126
+ await getPool().query(
127
+ `INSERT INTO router_metrics
128
+ (provider, model, requested_model, cache_hit, cache_type, latency_ms, cost_usd, error, error_type)
129
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
130
+ [
131
+ entry.provider ?? null,
132
+ entry.model ?? null,
133
+ entry.requested_model ?? null,
134
+ entry.cache_hit ?? null,
135
+ entry.cache_type ?? null,
136
+ entry.latency_ms ?? null,
137
+ entry.cost_usd ?? null,
138
+ entry.error ?? null,
139
+ entry.error_type ?? null
140
+ ]
141
+ );
142
+ } catch (err) {
143
+ // Same fire-and-forget contract as the file backend's own record():
144
+ // a metrics write must never be the reason a real request fails.
145
+ console.warn('⚠️ Failed to record metric (Postgres):', err.message);
146
+ }
147
+ }
148
+
149
+ async function readRecentFromPostgres(limit) {
150
+ await ensureSchema();
151
+ const result = await getPool().query(
152
+ `SELECT * FROM router_metrics ORDER BY ts DESC LIMIT $1`,
153
+ [limit]
154
+ );
155
+ return result.rows.reverse().map(rowFromPg); // oldest-first, matching readRecent()'s own file-backed order
156
+ }
157
+
158
+ async function rowsSinceFromPostgres(cutoffMs) {
159
+ await ensureSchema();
160
+ const result = await getPool().query(
161
+ `SELECT * FROM router_metrics WHERE ts >= $1 ORDER BY ts ASC`,
162
+ [new Date(cutoffMs)]
163
+ );
164
+ return result.rows.map(rowFromPg);
165
+ }
166
+
167
+ async function pruneOlderThanPostgres(days) {
168
+ await ensureSchema();
169
+ const result = await getPool().query(
170
+ `DELETE FROM router_metrics WHERE ts < now() - ($1::double precision * interval '1 day') RETURNING id`,
171
+ [days]
172
+ );
173
+ return result.rows.map((r) => r.id);
174
+ }
175
+
176
+ // Turns a raw provider error message into one of a handful of stable,
177
+ // human-meaningful buckets - the difference between a dashboard that says
178
+ // "openai: 100% error rate" (true, but not actionable without opening a
179
+ // terminal and reading a stack trace) and one that says
180
+ // "openai: authentication_error" (tells you exactly what to go fix).
181
+ // Anthropic's and OpenAI's SDKs both format a thrown error's .message the
182
+ // same way: "<http status> <json error body>" - so this tries that shape
183
+ // first (checking BOTH `.error.type`/`.error.code` for OpenAI's nesting
184
+ // and bare `.type` for Anthropic's), and falls back to keyword matching
185
+ // on the raw text for anything that doesn't parse (a network error has no
186
+ // JSON body at all, for instance - "unknown" is still more honest than
187
+ // guessing). Exported so server.js's error handlers and this module's own
188
+ // tests can both use the exact same classification, never two versions
189
+ // that could drift apart.
190
+ function classifyErrorType(message) {
191
+ if (!message) return 'unknown';
192
+ const jsonStart = message.indexOf('{');
193
+ if (jsonStart !== -1) {
194
+ try {
195
+ const body = JSON.parse(message.slice(jsonStart));
196
+ const type = body?.error?.type || body?.type;
197
+ const code = body?.error?.code;
198
+ if (type === 'authentication_error' || code === 'invalid_api_key') return 'authentication_error';
199
+ if (type === 'insufficient_quota' || code === 'insufficient_quota') return 'insufficient_quota';
200
+ if (type === 'rate_limit_error' || code === 'rate_limit_exceeded') return 'rate_limit_error';
201
+ if (type) return type; // whatever the provider itself called it - still more useful than "unknown"
202
+ } catch {
203
+ // Not JSON (or not shaped as expected) - fall through to keywords.
204
+ }
205
+ }
206
+ const lower = message.toLowerCase();
207
+ if (lower.includes('invalid') && lower.includes('key')) return 'authentication_error';
208
+ if (lower.includes('quota') || lower.includes('insufficient')) return 'insufficient_quota';
209
+ if (lower.includes('rate limit') || lower.includes('429')) return 'rate_limit_error';
210
+ return 'unknown';
211
+ }
212
+
213
+ // Historical note: METRICS_LOG_PATH used to name the one-and-only log
214
+ // file directly. It's kept as the configuration knob for backward
215
+ // compatibility, but now names the DIRECTORY those per-day files live
216
+ // in (its dirname) - existing deployments/tests that set it to a file
217
+ // path like ".../data/metrics.jsonl" keep working unchanged, since
218
+ // that file's directory is exactly where rotation stores things now.
219
+ const DATA_DIR = process.env.METRICS_LOG_PATH
220
+ ? path.dirname(process.env.METRICS_LOG_PATH)
221
+ : path.join(__dirname, 'data');
222
+
223
+ fs.mkdirSync(DATA_DIR, { recursive: true });
224
+
225
+ const FILE_NAME_PATTERN = /^metrics-(\d{4}-\d{2}-\d{2})\.jsonl$/;
226
+
227
+ function dateStringFor(date) {
228
+ return date.toISOString().slice(0, 10); // YYYY-MM-DD, UTC
229
+ }
230
+
231
+ function pathForDateString(dateStr) {
232
+ return path.join(DATA_DIR, `metrics-${dateStr}.jsonl`);
233
+ }
234
+
235
+ /** Today's log file path, in UTC. Exposed for tests; not meant for app code to write to directly - use record(). */
236
+ function currentLogPath() {
237
+ return pathForDateString(dateStringFor(new Date()));
238
+ }
239
+
240
+ /**
241
+ * Every rotated log file present, ascending by date. A file that
242
+ * doesn't match the naming pattern (stray file, .gitkeep, whatever) is
243
+ * silently ignored rather than treated as a parse error.
244
+ */
245
+ async function listLogFiles() {
246
+ let names;
247
+ try {
248
+ names = await fs.promises.readdir(DATA_DIR);
249
+ } catch {
250
+ return [];
251
+ }
252
+ return names
253
+ .map((name) => {
254
+ const match = name.match(FILE_NAME_PATTERN);
255
+ return match ? { date: match[1], path: path.join(DATA_DIR, name) } : null;
256
+ })
257
+ .filter(Boolean)
258
+ .sort((a, b) => a.date.localeCompare(b.date));
259
+ }
260
+
261
+ async function readFileRows(filePath) {
262
+ if (!fs.existsSync(filePath)) return [];
263
+ const rows = [];
264
+ const rl = readline.createInterface({
265
+ input: fs.createReadStream(filePath),
266
+ crlfDelay: Infinity
267
+ });
268
+ for await (const line of rl) {
269
+ if (!line.trim()) continue;
270
+ try {
271
+ rows.push(JSON.parse(line));
272
+ } catch {
273
+ // Skip a malformed line rather than aborting the whole read.
274
+ }
275
+ }
276
+ return rows;
277
+ }
278
+
279
+ // A plain per-call fs.appendFile was tried here first and was wrong:
280
+ // concurrent calls to record() (real traffic under load, or even just a
281
+ // tight test loop) fire multiple appendFile operations at once with no
282
+ // guaranteed completion order, so lines could interleave or land out of
283
+ // order - a real, reproducible flake this project's own tests caught
284
+ // (roughly 1 run in 5). A single long-lived write stream serializes its
285
+ // writes internally even when called back-to-back without awaiting
286
+ // each one, which is what actually guarantees ordering. The only thing
287
+ // a persistent stream needs extra is handling day rollover - resolved
288
+ // by checking today's date on every write and swapping to a fresh
289
+ // stream the moment it changes, so a long-running process still rotates
290
+ // correctly without ever writing yesterday's line into today's file or
291
+ // vice versa.
292
+ let currentStream = null;
293
+ let currentStreamDate = null;
294
+
295
+ function ensureWriteStream() {
296
+ const today = dateStringFor(new Date());
297
+ if (currentStream && currentStreamDate === today) return currentStream;
298
+ if (currentStream) currentStream.end();
299
+ currentStreamDate = today;
300
+ currentStream = fs.createWriteStream(pathForDateString(today), { flags: 'a' });
301
+ currentStream.on('error', (err) => {
302
+ console.warn('⚠️ Metrics log write error:', err.message);
303
+ });
304
+ return currentStream;
305
+ }
306
+
307
+ /**
308
+ * Record one completed request, appended to TODAY's file. Fire-and-
309
+ * forget by design - a metrics write must never be the reason a real
310
+ * request fails or slows down.
311
+ */
312
+ function record(entry) {
313
+ if (usingPostgres()) {
314
+ recordToPostgres(entry); // fire-and-forget - see its own comment
315
+ return;
316
+ }
317
+ try {
318
+ const line = JSON.stringify({ timestamp: new Date().toISOString(), ...entry }) + '\n';
319
+ ensureWriteStream().write(line);
320
+ } catch (err) {
321
+ console.warn('⚠️ Failed to record metric:', err.message);
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Read up to `limit` most recent records, scanning files newest-first
327
+ * and stopping as soon as enough rows have been collected - bounded by
328
+ * how many DAYS of data are needed to satisfy `limit`, not by the
329
+ * service's entire lifetime.
330
+ */
331
+ async function readRecent(limit = 500) {
332
+ if (usingPostgres()) return readRecentFromPostgres(limit);
333
+ const files = await listLogFiles();
334
+ const collected = [];
335
+ for (let i = files.length - 1; i >= 0 && collected.length < limit; i--) {
336
+ const rows = await readFileRows(files[i].path);
337
+ collected.unshift(...rows);
338
+ }
339
+ return collected.slice(-limit);
340
+ }
341
+
342
+ /**
343
+ * Rolling per-provider stats from the most recent `windowSize` requests
344
+ * to that provider: average latency and error rate. This is the signal
345
+ * router.js uses alongside the static cost table - a provider that is
346
+ * currently slow or failing shouldn't be picked just because its list
347
+ * price is lowest.
348
+ */
349
+ async function providerStats(windowSize = 50) {
350
+ const rows = await readRecent(2000);
351
+ const byProvider = {};
352
+
353
+ for (const row of rows) {
354
+ if (!row.provider) continue;
355
+ if (!byProvider[row.provider]) byProvider[row.provider] = [];
356
+ byProvider[row.provider].push(row);
357
+ }
358
+
359
+ const stats = {};
360
+ for (const [provider, entries] of Object.entries(byProvider)) {
361
+ const recent = entries.slice(-windowSize);
362
+ const errorEntries = recent.filter((e) => e.error);
363
+ const latencies = recent.filter((e) => !e.error && typeof e.latency_ms === 'number');
364
+ const avgLatencyMs = latencies.length
365
+ ? latencies.reduce((sum, e) => sum + e.latency_ms, 0) / latencies.length
366
+ : null;
367
+ // The MOST RECENT error only, not a tally of every type seen in the
368
+ // window - an alert should reflect "what's wrong right now," not a
369
+ // mix that might include something already fixed earlier in the
370
+ // window. Falls back to classifying on the fly for an older record
371
+ // written before error_type existed (see server.js) instead of
372
+ // silently going blank.
373
+ const lastError = errorEntries.length ? errorEntries[errorEntries.length - 1] : null;
374
+
375
+ stats[provider] = {
376
+ sampleSize: recent.length,
377
+ errorRate: recent.length ? errorEntries.length / recent.length : 0,
378
+ avgLatencyMs,
379
+ lastErrorType: lastError ? lastError.error_type || classifyErrorType(lastError.error) : null,
380
+ lastErrorAt: lastError ? lastError.timestamp : null
381
+ };
382
+ }
383
+ return stats;
384
+ }
385
+
386
+ /**
387
+ * Everything the cost dashboard needs for a calendar window, computed
388
+ * in one pass so the KPI numbers and the daily chart data are
389
+ * guaranteed to agree - they're two views of the exact same filtered
390
+ * rows, never two separate queries that could drift apart.
391
+ *
392
+ * Only the files whose OWN date falls inside [cutoff, today] are read
393
+ * at all - a 14-day query never opens a file from three months ago.
394
+ * The per-row timestamp filter still runs afterward (a file's date is
395
+ * an inclusion bound, not a correctness guarantee - see
396
+ * listLogFiles()'s "only matches the naming pattern" note).
397
+ *
398
+ * A "miss" bucket is anything dispatched to a provider that WASN'T a
399
+ * cache hit, successful or not - the error count is tracked alongside
400
+ * it per day for the table view and tooltip, but deliberately isn't
401
+ * its own stacked-chart series (see the dashboard page: three clean
402
+ * outcome series read better than four, and error rate has its own,
403
+ * more precise, KPI tile and per-provider breakdown instead).
404
+ *
405
+ * by_provider here is intentionally a different shape than
406
+ * providerStats() above: that one is a ROLLING window for routing
407
+ * health (router.js), this one is a CALENDAR window for reporting
408
+ * (the dashboard) and also carries request count and cost. Same
409
+ * underlying log, two different questions - not accidentally
410
+ * duplicated logic.
411
+ */
412
+ async function rangeSummary(days = 14) {
413
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
414
+
415
+ // Row-fetching prelude only - everything from here down (the actual
416
+ // daily/provider/hit-rate aggregation) is identical regardless of
417
+ // which backend supplied `rows`, so it's written once, below, shared
418
+ // by both.
419
+ let rows;
420
+ if (usingPostgres()) {
421
+ rows = await rowsSinceFromPostgres(cutoff); // already filtered server-side
422
+ } else {
423
+ const files = await listLogFiles();
424
+ const relevantFiles = files.filter((f) => {
425
+ // A file's own day spans [dayStart, dayStart + 24h) UTC; keep it if
426
+ // any part of that day could be on or after the cutoff.
427
+ const dayStart = Date.parse(`${f.date}T00:00:00.000Z`);
428
+ return dayStart + 24 * 60 * 60 * 1000 > cutoff;
429
+ });
430
+ rows = [];
431
+ for (const f of relevantFiles) {
432
+ rows = rows.concat(await readFileRows(f.path));
433
+ }
434
+ }
435
+ const inRange = rows.filter((r) => r.timestamp && Date.parse(r.timestamp) >= cutoff);
436
+
437
+ const dailyByDate = new Map();
438
+ const byProvider = {};
439
+ let totalCostUsd = 0;
440
+ let exactHits = 0;
441
+ let semanticHits = 0;
442
+ let misses = 0;
443
+ let errors = 0;
444
+
445
+ for (const row of inRange) {
446
+ const date = row.timestamp.slice(0, 10); // YYYY-MM-DD (UTC, from toISOString())
447
+ if (!dailyByDate.has(date)) {
448
+ dailyByDate.set(date, { date, requests: 0, cost_usd: 0, exact_hits: 0, semantic_hits: 0, misses: 0, errors: 0 });
449
+ }
450
+ const bucket = dailyByDate.get(date);
451
+ bucket.requests += 1;
452
+ bucket.cost_usd += row.cost_usd || 0;
453
+ totalCostUsd += row.cost_usd || 0;
454
+
455
+ if (row.provider) {
456
+ if (!byProvider[row.provider]) {
457
+ byProvider[row.provider] = { requests: 0, cost_usd: 0, errorCount: 0, latencies: [] };
458
+ }
459
+ const p = byProvider[row.provider];
460
+ p.requests += 1;
461
+ p.cost_usd += row.cost_usd || 0;
462
+ if (row.error) p.errorCount += 1;
463
+ else if (typeof row.latency_ms === 'number') p.latencies.push(row.latency_ms);
464
+ }
465
+
466
+ if (row.error) {
467
+ errors += 1;
468
+ bucket.errors += 1;
469
+ } else if (row.cache_hit && row.cache_type === 'semantic') {
470
+ semanticHits += 1;
471
+ bucket.semantic_hits += 1;
472
+ } else if (row.cache_hit) {
473
+ exactHits += 1;
474
+ bucket.exact_hits += 1;
475
+ } else {
476
+ misses += 1;
477
+ bucket.misses += 1;
478
+ }
479
+ }
480
+
481
+ const providerSummary = {};
482
+ for (const [name, p] of Object.entries(byProvider)) {
483
+ providerSummary[name] = {
484
+ requests: p.requests,
485
+ cost_usd: p.cost_usd,
486
+ errorRate: p.requests ? p.errorCount / p.requests : 0,
487
+ avgLatencyMs: p.latencies.length ? p.latencies.reduce((sum, v) => sum + v, 0) / p.latencies.length : null
488
+ };
489
+ }
490
+
491
+ return {
492
+ days,
493
+ sample_size: inRange.length,
494
+ total_cost_usd: totalCostUsd,
495
+ cache_hit_rate: {
496
+ exact: inRange.length ? exactHits / inRange.length : 0,
497
+ semantic: inRange.length ? semanticHits / inRange.length : 0,
498
+ combined: inRange.length ? (exactHits + semanticHits) / inRange.length : 0
499
+ },
500
+ error_rate: inRange.length ? errors / inRange.length : 0,
501
+ by_provider: providerSummary,
502
+ daily: [...dailyByDate.values()].sort((a, b) => a.date.localeCompare(b.date))
503
+ };
504
+ }
505
+
506
+ /**
507
+ * Explicit, opt-in cleanup: permanently deletes records older than
508
+ * `days`. NOT called automatically anywhere in this module - deleting
509
+ * cost/audit history is a retention-policy decision an operator makes
510
+ * on purpose (a cron job, a manual run), never something this module
511
+ * decides silently on their behalf. Returns the list of deleted file
512
+ * paths (file backend) or deleted row ids (Postgres backend) - the two
513
+ * backends' units of deletion genuinely differ, so the return value's
514
+ * shape does too; nothing in this codebase inspects the contents today,
515
+ * only that pruning happened and what it removed.
516
+ */
517
+ async function pruneOlderThan(days) {
518
+ if (usingPostgres()) return pruneOlderThanPostgres(days);
519
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
520
+ const files = await listLogFiles();
521
+ const deleted = [];
522
+ for (const f of files) {
523
+ const dayStart = Date.parse(`${f.date}T00:00:00.000Z`);
524
+ if (dayStart + 24 * 60 * 60 * 1000 <= cutoff) {
525
+ await fs.promises.unlink(f.path);
526
+ deleted.push(f.path);
527
+ }
528
+ }
529
+ return deleted;
530
+ }
531
+
532
+ // Test-only: closes the cached pool (if one was ever opened) so a test
533
+ // run doesn't hang on an open connection, and so the NEXT test that
534
+ // re-requires this module with a different DATABASE_URL gets a fresh
535
+ // pool/schema-ready state instead of reusing this one's.
536
+ async function closePostgresPoolForTests() {
537
+ if (pgPool) {
538
+ await pgPool.end();
539
+ pgPool = null;
540
+ schemaReady = null;
541
+ }
542
+ }
543
+
544
+ module.exports = {
545
+ record,
546
+ readRecent,
547
+ providerStats,
548
+ rangeSummary,
549
+ pruneOlderThan,
550
+ currentLogPath,
551
+ listLogFiles,
552
+ classifyErrorType,
553
+ usingPostgres,
554
+ closePostgresPoolForTests,
555
+ DATA_DIR
556
+ };
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "cachegate",
3
+ "version": "1.0.0",
4
+ "description": "Self-hostable, OpenAI-compatible LLM proxy: routes to the cheapest healthy provider, caches responses exactly and semantically, tracks cost and latency per call.",
5
+ "license": "MIT",
6
+ "main": "server.js",
7
+ "bin": {
8
+ "cachegate": "./server.js"
9
+ },
10
+ "engines": {
11
+ "node": ">=18.0.0"
12
+ },
13
+ "scripts": {
14
+ "start": "node server.js",
15
+ "test": "node --test"
16
+ },
17
+ "dependencies": {
18
+ "@anthropic-ai/sdk": "^0.115.0",
19
+ "dotenv": "^16.3.1",
20
+ "express": "^4.18.2",
21
+ "express-rate-limit": "^8.6.2",
22
+ "openai": "^4.28.4",
23
+ "pg": "^8.23.0",
24
+ "redis": "^4.7.0"
25
+ }
26
+ }