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/.gitattributes +12 -0
- package/.github/ISSUE_TEMPLATE/config.yml +5 -8
- package/Dockerfile +24 -16
- package/OPEN_SOURCE_ROADMAP.md +855 -554
- package/README.md +505 -428
- package/package.json +1 -1
- package/server.js +688 -668
- package/test/env-path.test.js +41 -0
package/server.js
CHANGED
|
@@ -1,668 +1,688 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// model-router/server.js
|
|
3
|
-
require('
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
// at
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
//
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
let
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
.
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
//
|
|
623
|
-
// (
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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 };
|