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