cachegate 1.1.1 → 1.3.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,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
- const authHeader = req.headers.authorization || '';
68
- const providedKey = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
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
- if (providedKey !== INTERNAL_KEY) {
71
- return res.status(401).json({ error: 'Unauthorized' });
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
- next();
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 auth and rate limiting -
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 two ways
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 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.
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
- if (!anthropicClient) anthropicClient = anthropicProvider.buildClient(process.env.ANTHROPIC_API_KEY);
156
- return anthropicClient;
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
- if (!openaiClient) openaiClient = openaiProvider.buildClient(process.env.OPENAI_API_KEY);
161
- return openaiClient;
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
- // 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
- });
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
- // 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 });
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 (!process.env.ANTHROPIC_API_KEY) {
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 (!process.env.OPENAI_API_KEY) {
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 (!process.env.ANTHROPIC_API_KEY) return res.status(500).json({ error: 'ANTHROPIC_API_KEY not configured' });
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 (!process.env.OPENAI_API_KEY) return res.status(500).json({ error: 'OPENAI_API_KEY not configured' });
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
- routingDecision = await router.pickCandidate(requestedModel);
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 (!process.env.ANTHROPIC_API_KEY) {
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 (!process.env.OPENAI_API_KEY) {
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
@@ -1,11 +0,0 @@
1
- node_modules
2
- npm-debug.log
3
- .env
4
- .env.local
5
- .git
6
- .gitignore
7
- test/
8
- .github/
9
- data/
10
- *.md
11
- !README.md
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