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/server.js ADDED
@@ -0,0 +1,668 @@
1
+ #!/usr/bin/env node
2
+ // model-router/server.js
3
+ require('dotenv').config();
4
+ const path = require('path');
5
+ const express = require('express');
6
+ const rateLimit = require('express-rate-limit');
7
+ const cache = require('./cache');
8
+ const semanticCache = require('./semanticCache');
9
+ const metrics = require('./metrics');
10
+ const router = require('./router');
11
+ const streaming = require('./streaming');
12
+ const anthropicProvider = require('./providers/anthropic');
13
+ const openaiProvider = require('./providers/openai');
14
+ const failover = require('./failover');
15
+
16
+ const app = express();
17
+ // No X-Powered-By: Express - free, standard hardening (avoids handing a
18
+ // public-facing service's framework fingerprint to every caller for no
19
+ // benefit).
20
+ app.disable('x-powered-by');
21
+
22
+ const PORT = process.env.PORT || 4000;
23
+ const INTERNAL_KEY = process.env.MODEL_ROUTER_INTERNAL_KEY;
24
+ const ALLOW_INSECURE_LOCAL_DEV = process.env.ALLOW_INSECURE_LOCAL_DEV === 'true';
25
+
26
+ // Fail closed, not open. A missing key used to mean "no auth enforced" -
27
+ // the .env.example calls the key "Required" but the code silently let
28
+ // requests through anyway, which is exactly the kind of thing that
29
+ // turns into an unauthenticated proxy sitting in front of real API keys
30
+ // the moment someone forgets to set it in a real deployment. This is
31
+ // checked right before the server actually starts listening (bottom of
32
+ // this file) rather than at module-load time, so requiring this file
33
+ // in-process (tests) doesn't need to satisfy it. Pure logic (no
34
+ // process.exit) so it's directly testable.
35
+ function isAuthConfigured() {
36
+ return Boolean(INTERNAL_KEY) || ALLOW_INSECURE_LOCAL_DEV;
37
+ }
38
+
39
+ // Internal authentication: every request must carry the shared internal key.
40
+ // Health check is intentionally public so load balancers can monitor the service.
41
+ function requireInternalKey(req, res, next) {
42
+ if (!INTERNAL_KEY) {
43
+ // Only reachable when ALLOW_INSECURE_LOCAL_DEV=true was explicitly set above.
44
+ return next();
45
+ }
46
+
47
+ const authHeader = req.headers.authorization || '';
48
+ const providedKey = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
49
+
50
+ if (providedKey !== INTERNAL_KEY) {
51
+ return res.status(401).json({ error: 'Unauthorized' });
52
+ }
53
+
54
+ next();
55
+ }
56
+
57
+ // Rate limiting: this proxy sits in front of paid, metered API keys -
58
+ // an unbounded client (a bug, a misbehaving script, abuse of a leaked
59
+ // internal key) has no ceiling today. Defaults are deliberately
60
+ // generous for real usage and overridable per deployment.
61
+ //
62
+ // The embedded deployment (this app's own MemoCode instance) has
63
+ // exactly ONE caller identity - memocode-backend, one service, one
64
+ // outbound IP - which means express-rate-limit's default per-IP
65
+ // keying doesn't separate individual end users at all: this ceiling is
66
+ // shared across EVERY MemoCode user's combined traffic, not per person.
67
+ // 60/60s (the original default) turned out to be uncomfortably close
68
+ // to what a single legitimate action can burst on its own: PDF
69
+ // summarize dispatches one call per chapter, sequentially, up to
70
+ // MAX_SUMMARIZED_CHAPTERS (40) - one person summarizing one long
71
+ // document could already use most of that budget alone, before any
72
+ // other user's traffic. Raised to something that comfortably covers
73
+ // real concurrent+bursty usage while still bounding a truly runaway
74
+ // loop (a retry bug, a leaked key) well before it could rack up
75
+ // meaningful real spend. NOT a fix for per-user fairness (a single
76
+ // abusive/looping caller could still crowd out everyone else within
77
+ // this shared ceiling) - that would need the router to key on a
78
+ // forwarded per-user identifier instead of the caller's IP, a real
79
+ // multi-tenancy step the router's own docs already flag as future
80
+ // scope (see ROADMAP.md's embedded/standalone split), not something
81
+ // this single-app deployment needs yet. Note for a STANDALONE
82
+ // self-hoster (as opposed to MemoCode's own single-caller embedded
83
+ // deployment the paragraph above describes): if your own callers each
84
+ // have distinct outbound IPs, this same per-IP default DOES separate
85
+ // them from each other - the "shared ceiling" caveat above is specific
86
+ // to a deployment with exactly one caller identity, not a general
87
+ // limitation of the rate limiter itself.
88
+ const rateLimiter = rateLimit({
89
+ windowMs: Number(process.env.RATE_LIMIT_WINDOW_MS) || 60_000,
90
+ limit: Number(process.env.RATE_LIMIT_MAX) || 300,
91
+ standardHeaders: true,
92
+ legacyHeaders: false,
93
+ message: { error: 'Too many requests - rate limit exceeded' }
94
+ });
95
+
96
+ // A separate, more generous limiter for the read-only aggregate
97
+ // endpoints (/stats, /dashboard/data) - security-review finding
98
+ // (2026-08-29): these were gated by the internal key but had NO rate
99
+ // limit at all, unlike /v1. Lower stakes than /v1 (no provider spend
100
+ // on the line), but still real server work (a metrics-store read +
101
+ // aggregation) that a leaked/shared key shouldn't be able to hammer
102
+ // without bound. Default comfortably covers the dashboard's own
103
+ // 30-second auto-refresh across several simultaneous viewers.
104
+ const readEndpointLimiter = rateLimit({
105
+ windowMs: Number(process.env.RATE_LIMIT_WINDOW_MS) || 60_000,
106
+ limit: Number(process.env.READ_RATE_LIMIT_MAX) || 120,
107
+ standardHeaders: true,
108
+ legacyHeaders: false,
109
+ message: { error: 'Too many requests - rate limit exceeded' }
110
+ });
111
+
112
+ // JSON body parsing scoped to /v1 only, AFTER auth and rate limiting -
113
+ // security-review finding (2026-08-29): this used to be
114
+ // app.use(express.json({limit:'50mb'})) applied GLOBALLY, before any
115
+ // auth check, on every route. That meant an ANONYMOUS caller could
116
+ // force up to 50MB of JSON parsing per request before ever being
117
+ // rejected with 401 - a real resource-exhaustion vector once this is
118
+ // exposed to the internet, not just a theoretical one. Fixed two ways
119
+ // at once: (1) scoped to /v1, the only route that ever reads a body -
120
+ // /health, /stats, /dashboard/data, /dashboard are all GET with
121
+ // nothing to parse; (2) ordered after requireInternalKey and
122
+ // rateLimiter, both cheap checks, so an unauthenticated or
123
+ // over-the-limit request is rejected before any parsing happens at
124
+ // all; (3) the limit itself dropped from 50mb to a much more realistic
125
+ // default - this router only ever handles plain text chat content (no
126
+ // image/multimodal support - see providers/*.js), so even a very long
127
+ // conversation history comfortably fits well under 2MB of raw JSON.
128
+ app.use('/v1', requireInternalKey, rateLimiter, express.json({ limit: process.env.JSON_BODY_LIMIT || '2mb' }));
129
+
130
+ // Lazy clients, constructed only when a request actually needs them.
131
+ let anthropicClient;
132
+ let openaiClient;
133
+
134
+ function getAnthropicClient() {
135
+ if (!anthropicClient) anthropicClient = anthropicProvider.buildClient(process.env.ANTHROPIC_API_KEY);
136
+ return anthropicClient;
137
+ }
138
+
139
+ function getOpenAiClient() {
140
+ if (!openaiClient) openaiClient = openaiProvider.buildClient(process.env.OPENAI_API_KEY);
141
+ return openaiClient;
142
+ }
143
+
144
+ function isModelAnthropic(model) {
145
+ return model && (model.startsWith('claude-'));
146
+ }
147
+
148
+ function isModelOpenAi(model) {
149
+ return model && (model.startsWith('gpt-') || model.startsWith('o1') || model.startsWith('o3'));
150
+ }
151
+
152
+ app.get('/health', (req, res) => {
153
+ res.json({
154
+ status: 'healthy',
155
+ redis_connected: cache.isConnected(),
156
+ semantic_cache_enabled: semanticCache.isEnabled(),
157
+ providers: {
158
+ anthropic: !!process.env.ANTHROPIC_API_KEY,
159
+ openai: !!process.env.OPENAI_API_KEY
160
+ },
161
+ routing_tiers: Object.keys(router.loadTiers()),
162
+ routing_strategy: router.loadStrategy()
163
+ });
164
+ });
165
+
166
+ // Raw aggregate data over the last N raw log records (a record-count
167
+ // window, not a calendar one) - a quick curl-able snapshot. The actual
168
+ // dashboard page (GET /dashboard) uses GET /dashboard/data below
169
+ // instead, which windows by calendar day so its date-range picker means
170
+ // what it says.
171
+ //
172
+ // exact vs. semantic hit rate are reported SEPARATELY, not blended into
173
+ // one number. An exact hit is a guarantee (identical request, identical
174
+ // cached response); a semantic hit is the router's best guess above a
175
+ // similarity threshold. Collapsing them into one "cache hit rate" is
176
+ // exactly the kind of thing that produces the inflated vendor numbers
177
+ // this project's own market research called out - see semanticCache.js.
178
+ app.get('/stats', requireInternalKey, readEndpointLimiter, async (req, res) => {
179
+ // Same fix as /dashboard/data below: `Number(x) || 200` would treat
180
+ // a legitimate ?limit=0 as falsy and silently substitute 200.
181
+ const parsedLimit = Number(req.query.limit);
182
+ const limit = Math.min(Number.isFinite(parsedLimit) ? parsedLimit : 200, 2000);
183
+ const [recent, byProvider] = await Promise.all([
184
+ metrics.readRecent(limit),
185
+ metrics.providerStats()
186
+ ]);
187
+ const totalCostUsd = recent.reduce((sum, r) => sum + (r.cost_usd || 0), 0);
188
+ const exactHits = recent.filter((r) => r.cache_hit && r.cache_type !== 'semantic').length;
189
+ const semanticHits = recent.filter((r) => r.cache_hit && r.cache_type === 'semantic').length;
190
+ res.json({
191
+ sample_size: recent.length,
192
+ cache_hit_rate: {
193
+ exact: recent.length ? exactHits / recent.length : 0,
194
+ semantic: recent.length ? semanticHits / recent.length : 0,
195
+ combined: recent.length ? (exactHits + semanticHits) / recent.length : 0
196
+ },
197
+ total_cost_usd: totalCostUsd,
198
+ by_provider: byProvider
199
+ });
200
+ });
201
+
202
+ // The cost dashboard's data source - everything in one response so the
203
+ // KPI tiles, the charts, and the provider table are all computed from
204
+ // the exact same filtered rows and can never disagree with each other.
205
+ // `days` is clamped to a sane range; the dashboard page's date-range
206
+ // picker calls this with 7/14/30.
207
+ app.get('/dashboard/data', requireInternalKey, readEndpointLimiter, async (req, res) => {
208
+ // NOT `Number(req.query.days) || 14` - that treats a legitimate
209
+ // ?days=0 as falsy and silently swaps in the default instead of
210
+ // clamping it to 1. Only an actually-missing/non-numeric value should
211
+ // fall back; a real 0 should clamp, not vanish.
212
+ const parsedDays = Number(req.query.days);
213
+ const requestedDays = Number.isFinite(parsedDays) ? parsedDays : 14;
214
+ const days = Math.min(Math.max(requestedDays, 1), 90);
215
+ const [summary, providerHealth] = await Promise.all([
216
+ metrics.rangeSummary(days),
217
+ // Deliberately the ROLLING window (same one router.js itself uses to
218
+ // decide routing health), not the calendar one above - "is something
219
+ // wrong RIGHT NOW" is a different question than "how did the last N
220
+ // days look," and answering it from stale calendar history would mean
221
+ // an alert for a key that got fixed yesterday still shows today.
222
+ metrics.providerStats()
223
+ ]);
224
+ // Only providers with an actual recent error - a healthy deployment
225
+ // sends an empty array, and the dashboard renders nothing for it,
226
+ // instead of a permanent "0.0%" row nobody needs to see.
227
+ const provider_alerts = Object.entries(providerHealth)
228
+ .filter(([, stat]) => stat.lastErrorType)
229
+ .map(([provider, stat]) => ({
230
+ provider,
231
+ error_type: stat.lastErrorType,
232
+ error_rate: stat.errorRate,
233
+ last_error_at: stat.lastErrorAt
234
+ }));
235
+ res.json({ ...summary, provider_alerts });
236
+ });
237
+
238
+ // The dashboard page itself - static HTML/CSS/JS, no server-side
239
+ // templating. It's served without auth (it's just markup, no data) and
240
+ // the page's own JS asks for the internal key and calls
241
+ // GET /dashboard/data with it - same bearer-token model as every other
242
+ // authenticated endpoint here, just entered once and kept in the
243
+ // browser's localStorage for convenience. See the README's "Cost
244
+ // dashboard" section for the real tradeoff that convenience carries.
245
+ app.get('/dashboard', (req, res) => {
246
+ res.sendFile(path.join(__dirname, 'public', 'dashboard.html'));
247
+ });
248
+
249
+ // Replays a cached entry (exact or semantic) as a synthetic SSE stream,
250
+ // so a streaming caller still gets the caching benefit instead of being
251
+ // forced onto the slow path just because it asked for stream:true. The
252
+ // whole cached answer arrives as one delta chunk - it was never
253
+ // generated token-by-token in the first place, so there's nothing to
254
+ // genuinely trickle out. (The match's similarity score, for a semantic
255
+ // hit, is already captured in the metrics.record() call the caller
256
+ // makes before reaching here - there's no slot for it in the
257
+ // OpenAI-compatible SSE frame shape, and adding one isn't worth
258
+ // deviating further from it.)
259
+ function streamCachedReplay(res, entry, cacheType) {
260
+ streaming.startSse(res);
261
+ const id = streaming.genId();
262
+ res.write(streaming.roleChunk({ id, model: entry.model }));
263
+ if (entry.content) res.write(streaming.deltaChunk({ id, model: entry.model, content: entry.content }));
264
+ res.write(streaming.finalChunk({
265
+ id,
266
+ model: entry.model,
267
+ usage: entry.usage,
268
+ cost_usd: 0,
269
+ provider: entry.provider,
270
+ cached: true,
271
+ cache_type: cacheType
272
+ }));
273
+ res.write(streaming.doneFrame());
274
+ res.end();
275
+ }
276
+
277
+ // Dispatches one non-streaming chat call to a specific provider -
278
+ // the same "is the key configured" checks the explicit-model branch
279
+ // below does inline, factored out here because the virtual-model
280
+ // failover loop needs to attempt this once per candidate, potentially
281
+ // against more than one provider in the same request. Throws an error
282
+ // with `.status` set so failover.isRetryableError() can decide whether
283
+ // it's worth trying the next candidate.
284
+ async function dispatchToProvider(provider, payload) {
285
+ if (provider === 'anthropic') {
286
+ if (!process.env.ANTHROPIC_API_KEY) {
287
+ throw Object.assign(new Error('ANTHROPIC_API_KEY not configured'), { status: 500 });
288
+ }
289
+ return anthropicProvider.chat(getAnthropicClient(), payload);
290
+ }
291
+ if (!process.env.OPENAI_API_KEY) {
292
+ throw Object.assign(new Error('OPENAI_API_KEY not configured'), { status: 500 });
293
+ }
294
+ return openaiProvider.chat(getOpenAiClient(), payload);
295
+ }
296
+
297
+ // The real streaming dispatch path: an actual cache miss, forwarded
298
+ // token-by-token to a provider. Scope: plain text content only - tools
299
+ // + stream:true is rejected before this is ever reached. Failover
300
+ // (below) is deliberately NOT applied here: by the time a streaming
301
+ // call could fail, SSE headers and the first frame (naming the
302
+ // ORIGINAL model) are already flushed to the client, so silently
303
+ // switching providers mid-stream would mean frames that disagree
304
+ // about which model answered - a materially harder problem than the
305
+ // non-streaming case, left as a documented gap rather than shipped
306
+ // half-working (see ROADMAP.md).
307
+ async function handleStreamingDispatch(req, res, payload, requestedModel, routingDecision) {
308
+ let providerName;
309
+ if (isModelAnthropic(payload.model)) {
310
+ providerName = 'anthropic';
311
+ if (!process.env.ANTHROPIC_API_KEY) return res.status(500).json({ error: 'ANTHROPIC_API_KEY not configured' });
312
+ } else if (isModelOpenAi(payload.model)) {
313
+ providerName = 'openai';
314
+ if (!process.env.OPENAI_API_KEY) return res.status(500).json({ error: 'OPENAI_API_KEY not configured' });
315
+ } else {
316
+ return res.status(400).json({ error: `Unsupported model: ${payload.model}` });
317
+ }
318
+
319
+ streaming.startSse(res);
320
+ const id = streaming.genId();
321
+ res.write(streaming.roleChunk({ id, model: payload.model }));
322
+
323
+ // If the client disconnects mid-stream, stop paying the provider for
324
+ // tokens nobody will read.
325
+ const controller = new AbortController();
326
+ req.on('close', () => controller.abort());
327
+
328
+ let result;
329
+ try {
330
+ const client = providerName === 'anthropic' ? getAnthropicClient() : getOpenAiClient();
331
+ const chatStreamFn = providerName === 'anthropic' ? anthropicProvider.chatStream : openaiProvider.chatStream;
332
+ result = await chatStreamFn(client, payload, {
333
+ signal: controller.signal,
334
+ onDelta: (text) => res.write(streaming.deltaChunk({ id, model: payload.model, content: text }))
335
+ });
336
+ } catch (err) {
337
+ console.error('โŒ Model router streaming error:', err.message);
338
+ // Headers are already sent by this point (SSE started above), so an
339
+ // HTTP error status is no longer possible - an in-band error frame
340
+ // is the honest signal a streaming client can actually observe,
341
+ // instead of an abrupt, unexplained connection close.
342
+ res.write(streaming.errorFrame(err.message));
343
+ res.write(streaming.doneFrame());
344
+ res.end();
345
+ metrics.record({
346
+ provider: providerName,
347
+ model: payload.model,
348
+ requested_model: requestedModel,
349
+ cache_hit: false,
350
+ error: err.message,
351
+ error_type: metrics.classifyErrorType(err.message)
352
+ });
353
+ return;
354
+ }
355
+
356
+ await cache.set(payload, result);
357
+ await semanticCache.store(payload, result);
358
+
359
+ res.write(streaming.finalChunk({
360
+ id,
361
+ model: result.model,
362
+ usage: result.usage,
363
+ cost_usd: result.cost_usd,
364
+ provider: result.provider,
365
+ cached: false
366
+ }));
367
+ res.write(streaming.doneFrame());
368
+ res.end();
369
+
370
+ metrics.record({
371
+ provider: result.provider,
372
+ model: result.model,
373
+ requested_model: requestedModel,
374
+ cache_hit: false,
375
+ latency_ms: result.latency_ms,
376
+ cost_usd: result.cost_usd
377
+ });
378
+ }
379
+
380
+ app.post('/v1/chat/completions', async (req, res) => {
381
+ const payload = req.body;
382
+
383
+ if (!payload || !payload.model || !Array.isArray(payload.messages)) {
384
+ return res.status(400).json({ error: 'Missing model or messages' });
385
+ }
386
+
387
+ const wantsStream = !!payload.stream;
388
+
389
+ // Tool-call streaming is a genuinely separate, harder problem -
390
+ // accumulating partial JSON arguments across chunks, possibly for
391
+ // more than one call in flight at once. Shipping a half-working
392
+ // version would be worse than this clear, honest "not yet." Plain
393
+ // text streaming (no tools) works below.
394
+ if (wantsStream && payload.tools) {
395
+ return res.status(400).json({
396
+ error: 'stream:true with tools is not yet supported. Send stream:false for tool-calling requests.'
397
+ });
398
+ }
399
+
400
+ const requestedModel = payload.model;
401
+ let routingDecision = null;
402
+
403
+ // Virtual model ("router:..."): this is the actual routing decision -
404
+ // pick the cheapest currently-healthy real model for the requested
405
+ // capability tier. Any other model name is dispatched exactly as
406
+ // before, unchanged - an explicit model choice is never overridden.
407
+ if (router.isVirtualModel(requestedModel)) {
408
+ routingDecision = await router.pickCandidate(requestedModel);
409
+ if (routingDecision.error) {
410
+ return res.status(400).json({ error: routingDecision.error });
411
+ }
412
+ payload.model = routingDecision.model;
413
+ }
414
+
415
+ // 1. Try the exact-match cache first - free, zero-risk, checked
416
+ // before anything else (keyed on the resolved concrete model, so a
417
+ // routed request and a direct request for the same concrete model
418
+ // share the same cache entries). A hit is served the same way
419
+ // whether or not the caller asked for stream:true - see
420
+ // streamCachedReplay() for the streaming case.
421
+ const cached = await cache.get(payload);
422
+ if (cached) {
423
+ metrics.record({
424
+ provider: cached.provider,
425
+ model: cached.model,
426
+ requested_model: requestedModel,
427
+ cache_hit: true,
428
+ cache_type: 'exact',
429
+ latency_ms: 0,
430
+ cost_usd: 0
431
+ });
432
+ if (wantsStream) return streamCachedReplay(res, cached, 'exact');
433
+ return res.json({
434
+ cached: true,
435
+ cache_type: 'exact',
436
+ provider: cached.provider,
437
+ model: cached.model,
438
+ routed_from: routingDecision ? requestedModel : undefined,
439
+ latency_ms: 0,
440
+ usage: cached.usage,
441
+ cost_usd: 0,
442
+ choices: [{
443
+ message: {
444
+ role: 'assistant',
445
+ content: cached.content,
446
+ tool_calls: cached.tool_calls
447
+ }
448
+ }]
449
+ });
450
+ }
451
+
452
+ // 1b. Exact match missed - try the semantic cache (a near-duplicate
453
+ // prompt, not an identical one). This costs one embedding call
454
+ // whether or not it finds anything; see semanticCache.js for why
455
+ // that's a deliberate tradeoff, not overhead to optimize away.
456
+ const semanticMatch = await semanticCache.findMatch(payload);
457
+ if (semanticMatch) {
458
+ const hit = semanticMatch.entry;
459
+ metrics.record({
460
+ provider: hit.provider,
461
+ model: hit.model,
462
+ requested_model: requestedModel,
463
+ cache_hit: true,
464
+ cache_type: 'semantic',
465
+ semantic_similarity: semanticMatch.similarity,
466
+ latency_ms: 0,
467
+ cost_usd: 0
468
+ });
469
+ if (wantsStream) return streamCachedReplay(res, hit, 'semantic');
470
+ return res.json({
471
+ cached: true,
472
+ cache_type: 'semantic',
473
+ semantic_similarity: semanticMatch.similarity,
474
+ provider: hit.provider,
475
+ model: hit.model,
476
+ routed_from: routingDecision ? requestedModel : undefined,
477
+ latency_ms: 0,
478
+ usage: hit.usage,
479
+ cost_usd: 0,
480
+ choices: [{
481
+ message: {
482
+ role: 'assistant',
483
+ content: hit.content,
484
+ tool_calls: hit.tool_calls
485
+ }
486
+ }]
487
+ });
488
+ }
489
+
490
+ // 2. Full miss - dispatch to a provider. The streaming and
491
+ // non-streaming paths diverge here because a streaming response has
492
+ // already started writing to `res` by the time an error could occur,
493
+ // so the two need different error-reporting strategies (see
494
+ // handleStreamingDispatch's error frame vs. this path's 502 JSON).
495
+ if (wantsStream) {
496
+ return handleStreamingDispatch(req, res, payload, requestedModel, routingDecision);
497
+ }
498
+
499
+ try {
500
+ let result;
501
+ let failedOver = false;
502
+
503
+ if (routingDecision) {
504
+ // Virtual model: try the ranked candidates in order (router.js's
505
+ // own health/strategy scoring already produced this order),
506
+ // falling over to the next one when a provider fails in a way
507
+ // that isn't the REQUEST's own fault - see
508
+ // failover.isRetryableError for exactly what that means. Every
509
+ // failed attempt is recorded on the dashboard the same way a
510
+ // non-failed-over error would be (below), so failover keeps the
511
+ // request succeeding without hiding the underlying provider
512
+ // problem from the Provider alerts table.
513
+ const attempt = await failover.dispatchWithFailover(
514
+ routingDecision.rankedCandidates,
515
+ (candidate) => dispatchToProvider(candidate.provider, { ...payload, model: candidate.model }),
516
+ (candidate, err) => metrics.record({
517
+ provider: candidate.provider,
518
+ model: candidate.model,
519
+ requested_model: requestedModel,
520
+ cache_hit: false,
521
+ error: err.message,
522
+ error_type: metrics.classifyErrorType(err.message)
523
+ })
524
+ );
525
+ result = attempt.result;
526
+ failedOver = attempt.attempts > 1;
527
+ payload.model = result.model; // the candidate that actually served it, if failover moved past the first choice
528
+ if (failedOver) {
529
+ console.warn(`โš ๏ธ Model router failover: ${routingDecision.provider}/${routingDecision.model} unavailable, served by ${attempt.candidate.provider}/${attempt.candidate.model} instead (attempt ${attempt.attempts}/${routingDecision.rankedCandidates.length})`);
530
+ }
531
+ } else if (isModelAnthropic(payload.model)) {
532
+ if (!process.env.ANTHROPIC_API_KEY) {
533
+ return res.status(500).json({ error: 'ANTHROPIC_API_KEY not configured' });
534
+ }
535
+ result = await anthropicProvider.chat(getAnthropicClient(), payload);
536
+ } else if (isModelOpenAi(payload.model)) {
537
+ if (!process.env.OPENAI_API_KEY) {
538
+ return res.status(500).json({ error: 'OPENAI_API_KEY not configured' });
539
+ }
540
+ result = await openaiProvider.chat(getOpenAiClient(), payload);
541
+ } else {
542
+ return res.status(400).json({ error: `Unsupported model: ${payload.model}` });
543
+ }
544
+
545
+ // Store in both caches - exact-match for identical future
546
+ // requests, semantic for near-duplicate ones. Both no-op quietly if
547
+ // their prerequisites (Redis / OPENAI_API_KEY) aren't configured.
548
+ await cache.set(payload, result);
549
+ await semanticCache.store(payload, result);
550
+
551
+ metrics.record({
552
+ provider: result.provider,
553
+ model: result.model,
554
+ requested_model: requestedModel,
555
+ cache_hit: false,
556
+ latency_ms: result.latency_ms,
557
+ cost_usd: result.cost_usd
558
+ });
559
+
560
+ res.json({
561
+ cached: false,
562
+ provider: result.provider,
563
+ model: result.model,
564
+ routed_from: routingDecision ? requestedModel : undefined,
565
+ failover: failedOver ? true : undefined,
566
+ latency_ms: result.latency_ms,
567
+ usage: result.usage,
568
+ cost_usd: result.cost_usd,
569
+ choices: [{
570
+ message: {
571
+ role: 'assistant',
572
+ content: result.content,
573
+ tool_calls: result.tool_calls
574
+ }
575
+ }]
576
+ });
577
+ } catch (err) {
578
+ console.error('โŒ Model router error:', err.message);
579
+ if (!routingDecision) {
580
+ // Virtual-model attempts already record one metrics entry PER
581
+ // candidate as each fails (see the onAttemptFailed callback
582
+ // above), including whichever one was last - recording again
583
+ // here would double-count it.
584
+ metrics.record({
585
+ provider: isModelAnthropic(payload.model) ? 'anthropic' : 'openai',
586
+ model: payload.model,
587
+ requested_model: requestedModel,
588
+ cache_hit: false,
589
+ error: err.message,
590
+ error_type: metrics.classifyErrorType(err.message)
591
+ });
592
+ }
593
+ res.status(502).json({ error: err.message });
594
+ }
595
+ });
596
+
597
+ // Step 14 (ROADMAP.md): metrics.pruneOlderThan() has existed since the
598
+ // day metrics.js was written, but nothing ever actually CALLED it - the
599
+ // log/table only ever grew. Retention default (90 days) deliberately
600
+ // matches /dashboard/data's own longest supported range (its own
601
+ // `days` clamp tops out at 90) - pruning any sooner than that would
602
+ // silently make the dashboard's own "Last 90 days" option lie. Runs
603
+ // once at boot (so a long-idle deployment doesn't wait a full day for
604
+ // its first cleanup) and once a day after that - a "delete old rows"
605
+ // job has no reason to run more often than that, and deliberately
606
+ // isn't tied to request volume at all (unlike everything else in this
607
+ // file, it should happen on a calendar cadence, not a traffic-shaped
608
+ // one).
609
+ const METRICS_RETENTION_DAYS = Number(process.env.METRICS_RETENTION_DAYS) || 90;
610
+ const PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000;
611
+ function runScheduledPrune() {
612
+ metrics
613
+ .pruneOlderThan(METRICS_RETENTION_DAYS)
614
+ .then((deleted) => {
615
+ if (deleted.length) {
616
+ console.log(`๐Ÿงน Pruned ${deleted.length} metrics record(s) older than ${METRICS_RETENTION_DAYS} days`);
617
+ }
618
+ })
619
+ .catch((err) => console.warn('โš ๏ธ Scheduled metrics prune failed:', err.message));
620
+ }
621
+
622
+ // Catch-all error handler - MUST be registered last, after every route
623
+ // (Express identifies error-handling middleware by its 4-argument
624
+ // signature, and only reaches it once something upstream calls
625
+ // next(err) or throws synchronously before a route's own try/catch).
626
+ //
627
+ // Real finding, security review 2026-08-29: without this, an error
628
+ // raised before a route handler runs (confirmed case: express.json()
629
+ // rejecting an oversized body) fell through to EXPRESS'S OWN default
630
+ // error handler - which returns a raw HTML page containing the FULL
631
+ // STACK TRACE, including this server's absolute filesystem paths, to
632
+ // whoever sent the request. Verified live with an actual oversized
633
+ // POST during this review, not assumed from reading the framework's
634
+ // docs. This returns the same plain JSON error shape every other
635
+ // endpoint here already uses, and never lets a stack trace reach the
636
+ // response body.
637
+ app.use((err, req, res, next) => {
638
+ if (res.headersSent) return next(err);
639
+ const status = err.status || err.statusCode || 500;
640
+ const message = status === 413 ? 'Request body too large.' : (err.message || 'Internal server error');
641
+ console.error('โŒ Unhandled error:', err.message);
642
+ res.status(status).json({ error: message });
643
+ });
644
+
645
+ if (require.main === module) {
646
+ if (!isAuthConfigured()) {
647
+ console.error(
648
+ 'โŒ MODEL_ROUTER_INTERNAL_KEY is not set. Refusing to start with an ' +
649
+ 'open /v1 endpoint. Set MODEL_ROUTER_INTERNAL_KEY, or set ' +
650
+ 'ALLOW_INSECURE_LOCAL_DEV=true if you understand the risk and this ' +
651
+ 'is a throwaway local instance.'
652
+ );
653
+ process.exit(1);
654
+ }
655
+ if (!INTERNAL_KEY && ALLOW_INSECURE_LOCAL_DEV) {
656
+ console.warn('โš ๏ธ Running with NO internal-key auth (ALLOW_INSECURE_LOCAL_DEV=true). Never do this in production.');
657
+ }
658
+ app.listen(PORT, () => {
659
+ console.log(`๐Ÿš€ cachegate listening on port ${PORT}`);
660
+ console.log(`๐Ÿ“ก Providers: Anthropic=${!!process.env.ANTHROPIC_API_KEY}, OpenAI=${!!process.env.OPENAI_API_KEY}`);
661
+ console.log(`๐Ÿ’พ Redis cache: ${cache.isConnected() ? 'connected' : 'disabled'}`);
662
+ console.log(`๐Ÿ—„๏ธ Metrics storage: ${metrics.usingPostgres() ? 'Postgres' : 'local JSONL'}`);
663
+ runScheduledPrune();
664
+ setInterval(runScheduledPrune, PRUNE_INTERVAL_MS);
665
+ });
666
+ }
667
+
668
+ module.exports = { app, isAuthConfigured };