cachegate 1.1.1 → 1.2.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/.env.example +127 -112
- package/README.md +31 -13
- package/cache.js +72 -51
- package/embeddings.js +42 -32
- package/metrics.js +609 -556
- package/package.json +15 -1
- package/redisClient.js +55 -45
- package/router.js +254 -218
- package/semanticCache.js +159 -154
- package/server.js +282 -113
- package/.dockerignore +0 -11
- package/.gitattributes +0 -12
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -33
- package/.github/ISSUE_TEMPLATE/config.yml +0 -5
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -29
- package/.github/PULL_REQUEST_TEMPLATE.md +0 -25
- package/.github/workflows/test.yml +0 -63
- package/CODE_OF_CONDUCT.md +0 -66
- package/CONTRIBUTING.md +0 -94
- package/Dockerfile +0 -24
- package/SECURITY.md +0 -39
- package/sync-oss-release.sh +0 -160
- package/test/auth-config.test.js +0 -27
- package/test/cache.test.js +0 -33
- package/test/embeddings.test.js +0 -24
- package/test/env-path.test.js +0 -41
- package/test/failover.test.js +0 -99
- package/test/metrics-postgres.test.js +0 -183
- package/test/metrics.test.js +0 -282
- package/test/router.test.js +0 -195
- package/test/semanticCache.test.js +0 -167
- package/test/server.test.js +0 -357
- package/test/streaming.test.js +0 -248
package/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// model-router/server.js
|
|
3
3
|
const path = require('path');
|
|
4
|
+
const crypto = require('crypto');
|
|
4
5
|
|
|
5
6
|
// Optional --env-path <file> / --env-path=<file> override, supported
|
|
6
7
|
// alongside (not instead of) the default cwd-based .env lookup: pass it
|
|
@@ -39,6 +40,27 @@ const app = express();
|
|
|
39
40
|
// benefit).
|
|
40
41
|
app.disable('x-powered-by');
|
|
41
42
|
|
|
43
|
+
// Defense-in-depth backstop, the same treatment the MemoCode backend
|
|
44
|
+
// already carries (its PRs #67/#71): Express 4 does NOT route an async
|
|
45
|
+
// route handler's rejected promise to the error middleware at the bottom
|
|
46
|
+
// of this file - left unguarded, Node's default since v15 is to crash
|
|
47
|
+
// the whole process, taking every other in-flight request with it. The
|
|
48
|
+
// route-level try/catches in /stats, /dashboard/data, and /v1's routing
|
|
49
|
+
// decision below are the primary fix; these hooks catch anything a
|
|
50
|
+
// future edit misses. The two failure shapes are deliberately treated
|
|
51
|
+
// differently (same reasoning as the backend's own PR #71): an
|
|
52
|
+
// unhandledRejection is one async operation's scoped failure - log and
|
|
53
|
+
// keep serving everyone else; an uncaughtException leaves the process in
|
|
54
|
+
// an unknown, possibly corrupted state - log and exit, letting whatever
|
|
55
|
+
// runs this (Docker, Render, Kubernetes) restart it clean.
|
|
56
|
+
process.on('unhandledRejection', (reason) => {
|
|
57
|
+
console.error('Unhandled promise rejection (server kept running):', reason);
|
|
58
|
+
});
|
|
59
|
+
process.on('uncaughtException', (err) => {
|
|
60
|
+
console.error('Uncaught exception (server exiting so the platform restarts it clean):', err);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
});
|
|
63
|
+
|
|
42
64
|
const PORT = process.env.PORT || 4000;
|
|
43
65
|
const INTERNAL_KEY = process.env.MODEL_ROUTER_INTERNAL_KEY;
|
|
44
66
|
const ALLOW_INSECURE_LOCAL_DEV = process.env.ALLOW_INSECURE_LOCAL_DEV === 'true';
|
|
@@ -58,20 +80,89 @@ function isAuthConfigured() {
|
|
|
58
80
|
|
|
59
81
|
// Internal authentication: every request must carry the shared internal key.
|
|
60
82
|
// 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
83
|
|
|
67
|
-
|
|
68
|
-
|
|
84
|
+
// Constant-time comparison of the shared internal key. A plain `!==`
|
|
85
|
+
// comparison short-circuits on the first differing byte, which in theory
|
|
86
|
+
// leaks how many leading bytes of a guessed key are correct via response
|
|
87
|
+
// timing. Both sides are hashed to equal length first (so
|
|
88
|
+
// timingSafeEqual's equal-length requirement holds regardless of the raw
|
|
89
|
+
// key lengths), then compared in constant time. Low practical severity for
|
|
90
|
+
// a single shared secret, but free to do right.
|
|
91
|
+
function constantTimeEqual(a, b) {
|
|
92
|
+
const ha = crypto.createHash('sha256').update(String(a)).digest();
|
|
93
|
+
const hb = crypto.createHash('sha256').update(String(b)).digest();
|
|
94
|
+
return crypto.timingSafeEqual(ha, hb);
|
|
95
|
+
}
|
|
69
96
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
97
|
+
// --- Extension seams (roadmap: engine/cloud "wrap it, don't fork it") ---
|
|
98
|
+
// A deployer needing real multi-tenancy (issued-key auth instead of one
|
|
99
|
+
// shared internal key, per-tenant BYOK provider keys, per-tenant rate
|
|
100
|
+
// limiting) overrides these via configure() below instead of forking
|
|
101
|
+
// this file - the fork this project's own Cachegate Cloud build had to
|
|
102
|
+
// maintain until now, duplicating every one of these decisions across a
|
|
103
|
+
// full copy of server.js. Every default here is EXACTLY today's
|
|
104
|
+
// single-tenant, unconfigured behavior - never calling configure()
|
|
105
|
+
// changes nothing about how this server behaves.
|
|
106
|
+
const seams = {
|
|
107
|
+
// (req) => Promise<{ scope, error? }> | { scope, error? }. Runs where
|
|
108
|
+
// requireInternalKey used to run unconditionally: as the FIRST /v1
|
|
109
|
+
// middleware, before the rate limiter even sees the request, exactly
|
|
110
|
+
// like today. `scope` is an opaque value (null = today's single
|
|
111
|
+
// global tenant) threaded through to every cache/metrics/router call
|
|
112
|
+
// this file makes below; `{ error: { status, message } }` rejects the
|
|
113
|
+
// request with that status before body parsing/dispatch ever runs.
|
|
114
|
+
// Default: today's shared-internal-key check, scope always null.
|
|
115
|
+
authenticate: async (req) => {
|
|
116
|
+
if (!INTERNAL_KEY) return { scope: null }; // only reachable when ALLOW_INSECURE_LOCAL_DEV=true
|
|
117
|
+
const authHeader = req.headers.authorization || '';
|
|
118
|
+
const providedKey = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
|
119
|
+
if (!providedKey || !constantTimeEqual(providedKey, INTERNAL_KEY)) {
|
|
120
|
+
return { scope: null, error: { status: 401, message: 'Unauthorized' } };
|
|
121
|
+
}
|
|
122
|
+
return { scope: null };
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
// (scope, provider) => string | null | Promise<string | null>. Default:
|
|
126
|
+
// today's single shared process.env key, the same for every scope. A
|
|
127
|
+
// BYOK-style deployer overrides this to look the key up per-scope
|
|
128
|
+
// instead - and since a real per-scope lookup is usually a database
|
|
129
|
+
// read (Cachegate Cloud's is a Postgres fetch + decrypt), the resolver
|
|
130
|
+
// may return a Promise; every call site below awaits it, which is a
|
|
131
|
+
// no-op for a synchronous resolver, so both shapes are first-class.
|
|
132
|
+
// (See the callers below: they never cache a client built from a
|
|
133
|
+
// non-default resolver's key, so a decrypted per-tenant secret never
|
|
134
|
+
// outlives the one request it was resolved for.)
|
|
135
|
+
resolveProviderKey: (scope, provider) =>
|
|
136
|
+
(provider === 'anthropic' ? process.env.ANTHROPIC_API_KEY : process.env.OPENAI_API_KEY) || null,
|
|
137
|
+
|
|
138
|
+
// Passed straight through as express-rate-limit's own `keyGenerator`.
|
|
139
|
+
// Default: undefined, so express-rate-limit's own per-IP default
|
|
140
|
+
// applies - exactly today's behavior. A deployer with a real per-
|
|
141
|
+
// caller identity (e.g. req.scope, once authenticate() sets one)
|
|
142
|
+
// overrides this to key the limiter on that instead of shared IP.
|
|
143
|
+
rateLimitKeyGenerator: undefined
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// Overrides one or more of the seams above. Safe to call anytime before
|
|
147
|
+
// the first request is handled (the functions above are read fresh on
|
|
148
|
+
// every request, not baked into route wiring at module-load time) -
|
|
149
|
+
// typically once, at process startup, by whatever imports this module.
|
|
150
|
+
// Never called anywhere in this codebase itself, so this file's own
|
|
151
|
+
// behavior is unaffected unless a caller opts in.
|
|
152
|
+
function configure(overrides = {}) {
|
|
153
|
+
Object.assign(seams, overrides);
|
|
154
|
+
}
|
|
73
155
|
|
|
74
|
-
|
|
156
|
+
async function requireInternalKey(req, res, next) {
|
|
157
|
+
try {
|
|
158
|
+
const { scope, error } = await seams.authenticate(req);
|
|
159
|
+
if (error) return res.status(error.status).json({ error: error.message });
|
|
160
|
+
req.scope = scope;
|
|
161
|
+
next();
|
|
162
|
+
} catch (err) {
|
|
163
|
+
console.error('❌ Auth error:', err.message);
|
|
164
|
+
res.status(500).json({ error: 'Authentication failed.' });
|
|
165
|
+
}
|
|
75
166
|
}
|
|
76
167
|
|
|
77
168
|
// Rate limiting: this proxy sits in front of paid, metered API keys -
|
|
@@ -105,11 +196,21 @@ function requireInternalKey(req, res, next) {
|
|
|
105
196
|
// them from each other - the "shared ceiling" caveat above is specific
|
|
106
197
|
// to a deployment with exactly one caller identity, not a general
|
|
107
198
|
// limitation of the rate limiter itself.
|
|
199
|
+
// keyGenerator reads `seams.rateLimitKeyGenerator` fresh on every
|
|
200
|
+
// request (a closure, not a value captured once here) - so a
|
|
201
|
+
// configure() call after this file loads (the normal case: a wrapping
|
|
202
|
+
// deployment configures before its first request, right after
|
|
203
|
+
// requiring this module) still takes effect. Falls back to
|
|
204
|
+
// express-rate-limit's own recommended IPv6-safe IP keying
|
|
205
|
+
// (ipKeyGenerator) when never configured - not a bare `req.ip`, which
|
|
206
|
+
// the library itself warns can let IPv6 users bypass limits (same
|
|
207
|
+
// default it would have used had this option been omitted entirely).
|
|
108
208
|
const rateLimiter = rateLimit({
|
|
109
209
|
windowMs: Number(process.env.RATE_LIMIT_WINDOW_MS) || 60_000,
|
|
110
210
|
limit: Number(process.env.RATE_LIMIT_MAX) || 300,
|
|
111
211
|
standardHeaders: true,
|
|
112
212
|
legacyHeaders: false,
|
|
213
|
+
keyGenerator: (req, res) => (seams.rateLimitKeyGenerator ? seams.rateLimitKeyGenerator(req, res) : rateLimit.ipKeyGenerator(req.ip)),
|
|
113
214
|
message: { error: 'Too many requests - rate limit exceeded' }
|
|
114
215
|
});
|
|
115
216
|
|
|
@@ -129,36 +230,56 @@ const readEndpointLimiter = rateLimit({
|
|
|
129
230
|
message: { error: 'Too many requests - rate limit exceeded' }
|
|
130
231
|
});
|
|
131
232
|
|
|
132
|
-
// JSON body parsing scoped to /v1 only, AFTER
|
|
233
|
+
// JSON body parsing scoped to /v1 only, AFTER rate limiting and auth -
|
|
133
234
|
// security-review finding (2026-08-29): this used to be
|
|
134
235
|
// app.use(express.json({limit:'50mb'})) applied GLOBALLY, before any
|
|
135
236
|
// auth check, on every route. That meant an ANONYMOUS caller could
|
|
136
237
|
// force up to 50MB of JSON parsing per request before ever being
|
|
137
238
|
// rejected with 401 - a real resource-exhaustion vector once this is
|
|
138
|
-
// exposed to the internet, not just a theoretical one. Fixed
|
|
239
|
+
// exposed to the internet, not just a theoretical one. Fixed three ways
|
|
139
240
|
// at once: (1) scoped to /v1, the only route that ever reads a body -
|
|
140
241
|
// /health, /stats, /dashboard/data, /dashboard are all GET with
|
|
141
|
-
// nothing to parse; (2) ordered
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
//
|
|
242
|
+
// nothing to parse; (2) ordered so the RATE LIMITER runs first, then
|
|
243
|
+
// auth, then body parsing - rate limiting before auth means even a
|
|
244
|
+
// failed-auth request (a brute-force key guess, say) is counted and
|
|
245
|
+
// throttled, rather than being rejected by requireInternalKey before
|
|
246
|
+
// ever reaching the limiter (the original order let unauthenticated
|
|
247
|
+
// callers hammer the auth check at full speed, outside the limiter's
|
|
248
|
+
// reach); both are cheap checks, so an over-the-limit OR unauthenticated
|
|
249
|
+
// request is rejected before any parsing happens at all; (3) the limit
|
|
250
|
+
// itself dropped from 50mb to a much more realistic default - this
|
|
251
|
+
// router only ever handles plain text chat content (no image/multimodal
|
|
252
|
+
// support - see providers/*.js), so even a very long conversation
|
|
253
|
+
// history comfortably fits well under 2MB of raw JSON.
|
|
254
|
+
app.use('/v1', rateLimiter, requireInternalKey, express.json({ limit: process.env.JSON_BODY_LIMIT || '2mb' }));
|
|
255
|
+
|
|
256
|
+
// Lazy clients, constructed only when a request actually needs them -
|
|
257
|
+
// but ONLY cached for the default global scope (null): that's the only
|
|
258
|
+
// case where `seams.resolveProviderKey` is guaranteed to return the
|
|
259
|
+
// same key on every call (today's single process.env key). Once a
|
|
260
|
+
// deployer configures a real per-scope resolver (BYOK, decrypted per
|
|
261
|
+
// tenant), every call below builds a fresh client instead of caching
|
|
262
|
+
// one - a decrypted secret must never outlive the one request it was
|
|
263
|
+
// resolved for.
|
|
151
264
|
let anthropicClient;
|
|
152
265
|
let openaiClient;
|
|
153
266
|
|
|
154
|
-
function getAnthropicClient() {
|
|
155
|
-
|
|
156
|
-
|
|
267
|
+
async function getAnthropicClient(scope) {
|
|
268
|
+
const key = await seams.resolveProviderKey(scope, 'anthropic');
|
|
269
|
+
if (scope == null) {
|
|
270
|
+
if (!anthropicClient) anthropicClient = anthropicProvider.buildClient(key);
|
|
271
|
+
return anthropicClient;
|
|
272
|
+
}
|
|
273
|
+
return anthropicProvider.buildClient(key);
|
|
157
274
|
}
|
|
158
275
|
|
|
159
|
-
function getOpenAiClient() {
|
|
160
|
-
|
|
161
|
-
|
|
276
|
+
async function getOpenAiClient(scope) {
|
|
277
|
+
const key = await seams.resolveProviderKey(scope, 'openai');
|
|
278
|
+
if (scope == null) {
|
|
279
|
+
if (!openaiClient) openaiClient = openaiProvider.buildClient(key);
|
|
280
|
+
return openaiClient;
|
|
281
|
+
}
|
|
282
|
+
return openaiProvider.buildClient(key);
|
|
162
283
|
}
|
|
163
284
|
|
|
164
285
|
function isModelAnthropic(model) {
|
|
@@ -169,17 +290,21 @@ function isModelOpenAi(model) {
|
|
|
169
290
|
return model && (model.startsWith('gpt-') || model.startsWith('o1') || model.startsWith('o3'));
|
|
170
291
|
}
|
|
171
292
|
|
|
293
|
+
// Public and deliberately minimal - a load balancer only ever needs "is
|
|
294
|
+
// this process up and can it reach its own dependencies", not internal
|
|
295
|
+
// configuration. redis_connected/semantic_cache_enabled are pure
|
|
296
|
+
// operational status (a monitoring dashboard's own "is the cache degraded
|
|
297
|
+
// right now" signal, not a secret); which PROVIDERS have a key configured,
|
|
298
|
+
// which routing TIERS/virtual models exist, and which STRATEGY picks
|
|
299
|
+
// between them used to live here too - internal routing configuration with
|
|
300
|
+
// no reason to be world-readable, unrelated to "is the process healthy".
|
|
301
|
+
// Anyone who legitimately needs that (an operator holding the internal
|
|
302
|
+
// key) gets it from GET /stats below instead.
|
|
172
303
|
app.get('/health', (req, res) => {
|
|
173
304
|
res.json({
|
|
174
305
|
status: 'healthy',
|
|
175
306
|
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()
|
|
307
|
+
semantic_cache_enabled: semanticCache.isEnabled()
|
|
183
308
|
});
|
|
184
309
|
});
|
|
185
310
|
|
|
@@ -196,27 +321,49 @@ app.get('/health', (req, res) => {
|
|
|
196
321
|
// exactly the kind of thing that produces the inflated vendor numbers
|
|
197
322
|
// this project's own market research called out - see semanticCache.js.
|
|
198
323
|
app.get('/stats', requireInternalKey, readEndpointLimiter, async (req, res) => {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
324
|
+
try {
|
|
325
|
+
// Same fix as /dashboard/data below: `Number(x) || 200` would treat
|
|
326
|
+
// a legitimate ?limit=0 as falsy and silently substitute 200.
|
|
327
|
+
const parsedLimit = Number(req.query.limit);
|
|
328
|
+
const limit = Math.min(Number.isFinite(parsedLimit) ? parsedLimit : 200, 2000);
|
|
329
|
+
const [recent, byProvider] = await Promise.all([
|
|
330
|
+
metrics.readRecent(req.scope, limit),
|
|
331
|
+
metrics.providerStats(req.scope)
|
|
332
|
+
]);
|
|
333
|
+
const totalCostUsd = recent.reduce((sum, r) => sum + (r.cost_usd || 0), 0);
|
|
334
|
+
const exactHits = recent.filter((r) => r.cache_hit && r.cache_type !== 'semantic').length;
|
|
335
|
+
const semanticHits = recent.filter((r) => r.cache_hit && r.cache_type === 'semantic').length;
|
|
336
|
+
res.json({
|
|
337
|
+
sample_size: recent.length,
|
|
338
|
+
cache_hit_rate: {
|
|
339
|
+
exact: recent.length ? exactHits / recent.length : 0,
|
|
340
|
+
semantic: recent.length ? semanticHits / recent.length : 0,
|
|
341
|
+
combined: recent.length ? (exactHits + semanticHits) / recent.length : 0
|
|
342
|
+
},
|
|
343
|
+
total_cost_usd: totalCostUsd,
|
|
344
|
+
by_provider: byProvider,
|
|
345
|
+
// Internal routing configuration - which providers have a key
|
|
346
|
+
// configured, which virtual-model tiers exist, and which strategy picks
|
|
347
|
+
// between them - moved here from the public GET /health (security-
|
|
348
|
+
// review finding, 2026-09-02): this endpoint already requires the
|
|
349
|
+
// internal key, /health never did.
|
|
350
|
+
providers: {
|
|
351
|
+
anthropic: Boolean(await seams.resolveProviderKey(req.scope, 'anthropic')),
|
|
352
|
+
openai: Boolean(await seams.resolveProviderKey(req.scope, 'openai'))
|
|
353
|
+
},
|
|
354
|
+
routing_tiers: Object.keys(router.loadTiers()),
|
|
355
|
+
routing_strategy: router.loadStrategy()
|
|
356
|
+
});
|
|
357
|
+
} catch (err) {
|
|
358
|
+
// A metrics-store failure is OUR dependency failing - surface it as
|
|
359
|
+
// an error rather than silently zeroing the numbers (a dashboard that
|
|
360
|
+
// quietly shows empty data during an outage is a lie), and never let
|
|
361
|
+
// it become an unhandled rejection: Express 4 doesn't route those to
|
|
362
|
+
// the error middleware, and Node's default would crash the process
|
|
363
|
+
// for every other in-flight request too.
|
|
364
|
+
console.error('Stats read error:', err.message);
|
|
365
|
+
res.status(500).json({ error: 'Failed to read metrics.' });
|
|
366
|
+
}
|
|
220
367
|
});
|
|
221
368
|
|
|
222
369
|
// The cost dashboard's data source - everything in one response so the
|
|
@@ -225,34 +372,43 @@ app.get('/stats', requireInternalKey, readEndpointLimiter, async (req, res) => {
|
|
|
225
372
|
// `days` is clamped to a sane range; the dashboard page's date-range
|
|
226
373
|
// picker calls this with 7/14/30.
|
|
227
374
|
app.get('/dashboard/data', requireInternalKey, readEndpointLimiter, async (req, res) => {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
provider,
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
375
|
+
try {
|
|
376
|
+
// NOT `Number(req.query.days) || 14` - that treats a legitimate
|
|
377
|
+
// ?days=0 as falsy and silently swaps in the default instead of
|
|
378
|
+
// clamping it to 1. Only an actually-missing/non-numeric value should
|
|
379
|
+
// fall back; a real 0 should clamp, not vanish.
|
|
380
|
+
const parsedDays = Number(req.query.days);
|
|
381
|
+
const requestedDays = Number.isFinite(parsedDays) ? parsedDays : 14;
|
|
382
|
+
const days = Math.min(Math.max(requestedDays, 1), 90);
|
|
383
|
+
const [summary, providerHealth] = await Promise.all([
|
|
384
|
+
metrics.rangeSummary(req.scope, days),
|
|
385
|
+
// Deliberately the ROLLING window (same one router.js itself uses to
|
|
386
|
+
// decide routing health), not the calendar one above - "is something
|
|
387
|
+
// wrong RIGHT NOW" is a different question than "how did the last N
|
|
388
|
+
// days look," and answering it from stale calendar history would mean
|
|
389
|
+
// an alert for a key that got fixed yesterday still shows today.
|
|
390
|
+
metrics.providerStats(req.scope)
|
|
391
|
+
]);
|
|
392
|
+
// Only providers with an actual recent error - a healthy deployment
|
|
393
|
+
// sends an empty array, and the dashboard renders nothing for it,
|
|
394
|
+
// instead of a permanent "0.0%" row nobody needs to see.
|
|
395
|
+
const provider_alerts = Object.entries(providerHealth)
|
|
396
|
+
.filter(([, stat]) => stat.lastErrorType)
|
|
397
|
+
.map(([provider, stat]) => ({
|
|
398
|
+
provider,
|
|
399
|
+
error_type: stat.lastErrorType,
|
|
400
|
+
error_rate: stat.errorRate,
|
|
401
|
+
last_error_at: stat.lastErrorAt
|
|
402
|
+
}));
|
|
403
|
+
res.json({ ...summary, provider_alerts });
|
|
404
|
+
} catch (err) {
|
|
405
|
+
// Same shape as /stats above: a metrics-store failure is an error to
|
|
406
|
+
// surface, never an unhandled rejection that crashes the process -
|
|
407
|
+
// and never silently-empty data an operator would mistake for "no
|
|
408
|
+
// traffic".
|
|
409
|
+
console.error('Dashboard data read error:', err.message);
|
|
410
|
+
res.status(500).json({ error: 'Failed to read metrics.' });
|
|
411
|
+
}
|
|
256
412
|
});
|
|
257
413
|
|
|
258
414
|
// The dashboard page itself - static HTML/CSS/JS, no server-side
|
|
@@ -301,17 +457,17 @@ function streamCachedReplay(res, entry, cacheType) {
|
|
|
301
457
|
// against more than one provider in the same request. Throws an error
|
|
302
458
|
// with `.status` set so failover.isRetryableError() can decide whether
|
|
303
459
|
// it's worth trying the next candidate.
|
|
304
|
-
async function dispatchToProvider(provider, payload) {
|
|
460
|
+
async function dispatchToProvider(scope, provider, payload) {
|
|
305
461
|
if (provider === 'anthropic') {
|
|
306
|
-
if (!
|
|
462
|
+
if (!(await seams.resolveProviderKey(scope, 'anthropic'))) {
|
|
307
463
|
throw Object.assign(new Error('ANTHROPIC_API_KEY not configured'), { status: 500 });
|
|
308
464
|
}
|
|
309
|
-
return anthropicProvider.chat(getAnthropicClient(), payload);
|
|
465
|
+
return anthropicProvider.chat(await getAnthropicClient(scope), payload);
|
|
310
466
|
}
|
|
311
|
-
if (!
|
|
467
|
+
if (!(await seams.resolveProviderKey(scope, 'openai'))) {
|
|
312
468
|
throw Object.assign(new Error('OPENAI_API_KEY not configured'), { status: 500 });
|
|
313
469
|
}
|
|
314
|
-
return openaiProvider.chat(getOpenAiClient(), payload);
|
|
470
|
+
return openaiProvider.chat(await getOpenAiClient(scope), payload);
|
|
315
471
|
}
|
|
316
472
|
|
|
317
473
|
// The real streaming dispatch path: an actual cache miss, forwarded
|
|
@@ -325,13 +481,14 @@ async function dispatchToProvider(provider, payload) {
|
|
|
325
481
|
// non-streaming case, left as a documented gap rather than shipped
|
|
326
482
|
// half-working (see ROADMAP.md).
|
|
327
483
|
async function handleStreamingDispatch(req, res, payload, requestedModel, routingDecision) {
|
|
484
|
+
const scope = req.scope;
|
|
328
485
|
let providerName;
|
|
329
486
|
if (isModelAnthropic(payload.model)) {
|
|
330
487
|
providerName = 'anthropic';
|
|
331
|
-
if (!
|
|
488
|
+
if (!(await seams.resolveProviderKey(scope, 'anthropic'))) return res.status(500).json({ error: 'ANTHROPIC_API_KEY not configured' });
|
|
332
489
|
} else if (isModelOpenAi(payload.model)) {
|
|
333
490
|
providerName = 'openai';
|
|
334
|
-
if (!
|
|
491
|
+
if (!(await seams.resolveProviderKey(scope, 'openai'))) return res.status(500).json({ error: 'OPENAI_API_KEY not configured' });
|
|
335
492
|
} else {
|
|
336
493
|
return res.status(400).json({ error: `Unsupported model: ${payload.model}` });
|
|
337
494
|
}
|
|
@@ -347,7 +504,7 @@ async function handleStreamingDispatch(req, res, payload, requestedModel, routin
|
|
|
347
504
|
|
|
348
505
|
let result;
|
|
349
506
|
try {
|
|
350
|
-
const client = providerName === 'anthropic' ? getAnthropicClient() : getOpenAiClient();
|
|
507
|
+
const client = providerName === 'anthropic' ? await getAnthropicClient(scope) : await getOpenAiClient(scope);
|
|
351
508
|
const chatStreamFn = providerName === 'anthropic' ? anthropicProvider.chatStream : openaiProvider.chatStream;
|
|
352
509
|
result = await chatStreamFn(client, payload, {
|
|
353
510
|
signal: controller.signal,
|
|
@@ -362,7 +519,7 @@ async function handleStreamingDispatch(req, res, payload, requestedModel, routin
|
|
|
362
519
|
res.write(streaming.errorFrame(err.message));
|
|
363
520
|
res.write(streaming.doneFrame());
|
|
364
521
|
res.end();
|
|
365
|
-
metrics.record({
|
|
522
|
+
metrics.record(scope, {
|
|
366
523
|
provider: providerName,
|
|
367
524
|
model: payload.model,
|
|
368
525
|
requested_model: requestedModel,
|
|
@@ -373,8 +530,8 @@ async function handleStreamingDispatch(req, res, payload, requestedModel, routin
|
|
|
373
530
|
return;
|
|
374
531
|
}
|
|
375
532
|
|
|
376
|
-
await cache.set(payload, result);
|
|
377
|
-
await semanticCache.store(payload, result);
|
|
533
|
+
await cache.set(scope, payload, result);
|
|
534
|
+
await semanticCache.store(scope, payload, result);
|
|
378
535
|
|
|
379
536
|
res.write(streaming.finalChunk({
|
|
380
537
|
id,
|
|
@@ -387,7 +544,7 @@ async function handleStreamingDispatch(req, res, payload, requestedModel, routin
|
|
|
387
544
|
res.write(streaming.doneFrame());
|
|
388
545
|
res.end();
|
|
389
546
|
|
|
390
|
-
metrics.record({
|
|
547
|
+
metrics.record(scope, {
|
|
391
548
|
provider: result.provider,
|
|
392
549
|
model: result.model,
|
|
393
550
|
requested_model: requestedModel,
|
|
@@ -399,6 +556,7 @@ async function handleStreamingDispatch(req, res, payload, requestedModel, routin
|
|
|
399
556
|
|
|
400
557
|
app.post('/v1/chat/completions', async (req, res) => {
|
|
401
558
|
const payload = req.body;
|
|
559
|
+
const scope = req.scope; // set by requireInternalKey/seams.authenticate - null unless configured
|
|
402
560
|
|
|
403
561
|
if (!payload || !payload.model || !Array.isArray(payload.messages)) {
|
|
404
562
|
return res.status(400).json({ error: 'Missing model or messages' });
|
|
@@ -425,7 +583,18 @@ app.post('/v1/chat/completions', async (req, res) => {
|
|
|
425
583
|
// capability tier. Any other model name is dispatched exactly as
|
|
426
584
|
// before, unchanged - an explicit model choice is never overridden.
|
|
427
585
|
if (router.isVirtualModel(requestedModel)) {
|
|
428
|
-
|
|
586
|
+
// Backstop: router.js already degrades a metrics-store failure to
|
|
587
|
+
// cost-only ranking internally (see its providerStats fallback), so
|
|
588
|
+
// this catch should be unreachable today - but an unguarded await
|
|
589
|
+
// here was a process-crash vector under Express 4 (async rejections
|
|
590
|
+
// never reach the error middleware), so guard it anyway rather than
|
|
591
|
+
// trust a comment to hold against future edits.
|
|
592
|
+
try {
|
|
593
|
+
routingDecision = await router.pickCandidate(requestedModel, scope);
|
|
594
|
+
} catch (err) {
|
|
595
|
+
console.error('Routing decision error:', err.message);
|
|
596
|
+
return res.status(500).json({ error: 'Routing decision failed.' });
|
|
597
|
+
}
|
|
429
598
|
if (routingDecision.error) {
|
|
430
599
|
return res.status(400).json({ error: routingDecision.error });
|
|
431
600
|
}
|
|
@@ -438,9 +607,9 @@ app.post('/v1/chat/completions', async (req, res) => {
|
|
|
438
607
|
// share the same cache entries). A hit is served the same way
|
|
439
608
|
// whether or not the caller asked for stream:true - see
|
|
440
609
|
// streamCachedReplay() for the streaming case.
|
|
441
|
-
const cached = await cache.get(payload);
|
|
610
|
+
const cached = await cache.get(scope, payload);
|
|
442
611
|
if (cached) {
|
|
443
|
-
metrics.record({
|
|
612
|
+
metrics.record(scope, {
|
|
444
613
|
provider: cached.provider,
|
|
445
614
|
model: cached.model,
|
|
446
615
|
requested_model: requestedModel,
|
|
@@ -473,10 +642,10 @@ app.post('/v1/chat/completions', async (req, res) => {
|
|
|
473
642
|
// prompt, not an identical one). This costs one embedding call
|
|
474
643
|
// whether or not it finds anything; see semanticCache.js for why
|
|
475
644
|
// that's a deliberate tradeoff, not overhead to optimize away.
|
|
476
|
-
const semanticMatch = await semanticCache.findMatch(payload);
|
|
645
|
+
const semanticMatch = await semanticCache.findMatch(scope, payload);
|
|
477
646
|
if (semanticMatch) {
|
|
478
647
|
const hit = semanticMatch.entry;
|
|
479
|
-
metrics.record({
|
|
648
|
+
metrics.record(scope, {
|
|
480
649
|
provider: hit.provider,
|
|
481
650
|
model: hit.model,
|
|
482
651
|
requested_model: requestedModel,
|
|
@@ -532,8 +701,8 @@ app.post('/v1/chat/completions', async (req, res) => {
|
|
|
532
701
|
// problem from the Provider alerts table.
|
|
533
702
|
const attempt = await failover.dispatchWithFailover(
|
|
534
703
|
routingDecision.rankedCandidates,
|
|
535
|
-
(candidate) => dispatchToProvider(candidate.provider, { ...payload, model: candidate.model }),
|
|
536
|
-
(candidate, err) => metrics.record({
|
|
704
|
+
(candidate) => dispatchToProvider(scope, candidate.provider, { ...payload, model: candidate.model }),
|
|
705
|
+
(candidate, err) => metrics.record(scope, {
|
|
537
706
|
provider: candidate.provider,
|
|
538
707
|
model: candidate.model,
|
|
539
708
|
requested_model: requestedModel,
|
|
@@ -549,15 +718,15 @@ app.post('/v1/chat/completions', async (req, res) => {
|
|
|
549
718
|
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
719
|
}
|
|
551
720
|
} else if (isModelAnthropic(payload.model)) {
|
|
552
|
-
if (!
|
|
721
|
+
if (!(await seams.resolveProviderKey(scope, 'anthropic'))) {
|
|
553
722
|
return res.status(500).json({ error: 'ANTHROPIC_API_KEY not configured' });
|
|
554
723
|
}
|
|
555
|
-
result = await anthropicProvider.chat(getAnthropicClient(), payload);
|
|
724
|
+
result = await anthropicProvider.chat(await getAnthropicClient(scope), payload);
|
|
556
725
|
} else if (isModelOpenAi(payload.model)) {
|
|
557
|
-
if (!
|
|
726
|
+
if (!(await seams.resolveProviderKey(scope, 'openai'))) {
|
|
558
727
|
return res.status(500).json({ error: 'OPENAI_API_KEY not configured' });
|
|
559
728
|
}
|
|
560
|
-
result = await openaiProvider.chat(getOpenAiClient(), payload);
|
|
729
|
+
result = await openaiProvider.chat(await getOpenAiClient(scope), payload);
|
|
561
730
|
} else {
|
|
562
731
|
return res.status(400).json({ error: `Unsupported model: ${payload.model}` });
|
|
563
732
|
}
|
|
@@ -565,10 +734,10 @@ app.post('/v1/chat/completions', async (req, res) => {
|
|
|
565
734
|
// Store in both caches - exact-match for identical future
|
|
566
735
|
// requests, semantic for near-duplicate ones. Both no-op quietly if
|
|
567
736
|
// their prerequisites (Redis / OPENAI_API_KEY) aren't configured.
|
|
568
|
-
await cache.set(payload, result);
|
|
569
|
-
await semanticCache.store(payload, result);
|
|
737
|
+
await cache.set(scope, payload, result);
|
|
738
|
+
await semanticCache.store(scope, payload, result);
|
|
570
739
|
|
|
571
|
-
metrics.record({
|
|
740
|
+
metrics.record(scope, {
|
|
572
741
|
provider: result.provider,
|
|
573
742
|
model: result.model,
|
|
574
743
|
requested_model: requestedModel,
|
|
@@ -601,7 +770,7 @@ app.post('/v1/chat/completions', async (req, res) => {
|
|
|
601
770
|
// candidate as each fails (see the onAttemptFailed callback
|
|
602
771
|
// above), including whichever one was last - recording again
|
|
603
772
|
// here would double-count it.
|
|
604
|
-
metrics.record({
|
|
773
|
+
metrics.record(scope, {
|
|
605
774
|
provider: isModelAnthropic(payload.model) ? 'anthropic' : 'openai',
|
|
606
775
|
model: payload.model,
|
|
607
776
|
requested_model: requestedModel,
|
|
@@ -685,4 +854,4 @@ if (require.main === module) {
|
|
|
685
854
|
});
|
|
686
855
|
}
|
|
687
856
|
|
|
688
|
-
module.exports = { app, isAuthConfigured, resolveEnvPathFromArgv };
|
|
857
|
+
module.exports = { app, isAuthConfigured, resolveEnvPathFromArgv, configure };
|
package/.dockerignore
DELETED
package/.gitattributes
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
# Force LF regardless of the checking-out platform's git config
|
|
2
|
-
# (core.autocrlf). Real incident this exists to prevent: a Windows
|
|
3
|
-
# checkout of sync-oss-release.sh got CRLF line endings, which broke
|
|
4
|
-
# it immediately with "$'\r': command not found" - no bash interpreter
|
|
5
|
-
# on any platform handles a CRLF shebang/script correctly. See
|
|
6
|
-
# OPEN_SOURCE_ROADMAP.md step 16.
|
|
7
|
-
*.sh text eol=lf
|
|
8
|
-
|
|
9
|
-
# Everything else: let git decide text vs. binary automatically, but
|
|
10
|
-
# normalize line endings to LF in the repository itself either way -
|
|
11
|
-
# this project has no reason to ever store CRLF.
|
|
12
|
-
* text=auto eol=lf
|