cachegate 1.3.0 → 1.4.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 +83 -4
- package/LICENSE +21 -21
- package/README.md +154 -15
- package/cache.js +133 -9
- package/cascade.js +206 -0
- package/coalescing.js +65 -0
- package/embeddings.js +78 -16
- package/failover.js +76 -76
- package/guardrails.js +80 -0
- package/metrics.js +93 -7
- package/package.json +12 -2
- package/pii.js +128 -0
- package/providers/anthropic.js +122 -122
- package/providers/deepseek.js +194 -0
- package/providers/index.js +62 -0
- package/providers/openai.js +122 -114
- package/providers/openrouter.js +233 -0
- package/public/dashboard.html +1120 -1119
- package/router.js +33 -4
- package/semanticCache.js +115 -3
- package/server.js +494 -99
- package/streaming.js +77 -77
- package/tracing.js +97 -0
package/server.js
CHANGED
|
@@ -30,11 +30,116 @@ const semanticCache = require('./semanticCache');
|
|
|
30
30
|
const metrics = require('./metrics');
|
|
31
31
|
const router = require('./router');
|
|
32
32
|
const streaming = require('./streaming');
|
|
33
|
-
const
|
|
34
|
-
|
|
33
|
+
const providers = require('./providers');
|
|
34
|
+
// Kept as aliases: the cascade/grader paths below name these two explicitly
|
|
35
|
+
// (OpenAI's logprobs are the only native confidence signal; Anthropic falls
|
|
36
|
+
// back to a grader model). Everything that generalizes goes through
|
|
37
|
+
// `providers` - see providers/index.js for why.
|
|
38
|
+
const anthropicProvider = providers.get('anthropic');
|
|
39
|
+
const openaiProvider = providers.get('openai');
|
|
35
40
|
const failover = require('./failover');
|
|
41
|
+
const coalescing = require('./coalescing');
|
|
42
|
+
const cascade = require('./cascade');
|
|
43
|
+
const tracing = require('./tracing');
|
|
44
|
+
const pii = require('./pii');
|
|
45
|
+
const guardrails = require('./guardrails');
|
|
46
|
+
|
|
47
|
+
// Status-coded error for config/validation failures inside the dispatch
|
|
48
|
+
// (the /v1 catch below reads err.status to pick the HTTP code, but ONLY
|
|
49
|
+
// on the explicit-model path - see that catch's own comment on why the
|
|
50
|
+
// virtual-model/failover path never honors it). skipMetric marks a
|
|
51
|
+
// pre-dispatch validation failure (missing key, unsupported model) that
|
|
52
|
+
// never actually reached a provider - the ORIGINAL inline
|
|
53
|
+
// `return res.status(...)` code never called metrics.record() for these
|
|
54
|
+
// either, so this restores that (a static misconfiguration isn't a live
|
|
55
|
+
// provider-health signal; it shouldn't pollute the Provider alerts table
|
|
56
|
+
// the same way a real dispatch failure does).
|
|
57
|
+
function httpError(status, message, { skipMetric = false } = {}) {
|
|
58
|
+
const err = new Error(message);
|
|
59
|
+
err.status = status;
|
|
60
|
+
err.skipMetric = skipMetric;
|
|
61
|
+
return err;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Applies pii.redact() to every string in a messages array (text content
|
|
65
|
+
// and the `text` field of multimodal content parts), returning a NEW
|
|
66
|
+
// array. Non-string content (images, etc.) passes through untouched. Only
|
|
67
|
+
// called when pii.isEnabled(), so redaction is identity when the flag is
|
|
68
|
+
// off.
|
|
69
|
+
function redactMessages(messages) {
|
|
70
|
+
return messages.map((m) => {
|
|
71
|
+
if (!m || typeof m !== 'object') return m;
|
|
72
|
+
if (typeof m.content === 'string') {
|
|
73
|
+
return { ...m, content: pii.redact(m.content).text };
|
|
74
|
+
}
|
|
75
|
+
if (Array.isArray(m.content)) {
|
|
76
|
+
return {
|
|
77
|
+
...m,
|
|
78
|
+
content: m.content.map((part) => {
|
|
79
|
+
if (typeof part === 'string') return pii.redact(part).text;
|
|
80
|
+
if (part && typeof part === 'object' && typeof part.text === 'string') {
|
|
81
|
+
return { ...part, text: pii.redact(part.text).text };
|
|
82
|
+
}
|
|
83
|
+
return part;
|
|
84
|
+
})
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return m;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
36
90
|
|
|
37
91
|
const app = express();
|
|
92
|
+
// Step 36 (observability): initialize OTel once, at module load. A no-op
|
|
93
|
+
// unless OTEL_ENABLED + OTEL_EXPORTER_OTLP_ENDPOINT are both set (see
|
|
94
|
+
// tracing.js) - so both the standalone server and a wrapping deployment
|
|
95
|
+
// (cachegate-cloud's cloud-server.js) get tracing without any extra call.
|
|
96
|
+
tracing.initTracing();
|
|
97
|
+
// `trust proxy`, now CONFIGURABLE and secure by default. The previous hardcoded `1` was chosen for
|
|
98
|
+
// Cachegate Cloud's single-hop topology (2026-09-04) and is right THERE, but it is the wrong default
|
|
99
|
+
// for the topology this engine's own README documents (`docker run -p 4000:4000`: no proxy at all).
|
|
100
|
+
// In that topology `1` trusts a client-controlled X-Forwarded-For, so any caller can present a fresh
|
|
101
|
+
// IP on every request and the per-IP limiter on the key-holding routes is defeated - fail-OPEN, and
|
|
102
|
+
// silent. `false` behind a real proxy is fail-CLOSED and loud: the limiter keys globally, one env var
|
|
103
|
+
// away from correct. Every other security decision in this file fails closed (no DATABASE_URL, no
|
|
104
|
+
// API_KEY_ENCRYPTION_SECRET, auth) and this should not be the exception.
|
|
105
|
+
//
|
|
106
|
+
// Deployment note: a proxied deployment MUST now set TRUST_PROXY explicitly - Cachegate Cloud sets
|
|
107
|
+
// TRUST_PROXY=1 in render.yaml and RENDER-ENV-MAP.md. Upgrade impact is a boot-time warning on Render
|
|
108
|
+
// (see below) plus a CHANGELOG entry, not a silent change of limiter scope.
|
|
109
|
+
function resolveTrustProxy(raw) {
|
|
110
|
+
const value = raw == null ? '' : String(raw).trim();
|
|
111
|
+
if (value === '') {
|
|
112
|
+
// Only warn where a proxy demonstrably exists: Render sets RENDER/RENDER_EXTERNAL_URL. Warning on
|
|
113
|
+
// every unset boot would be noise in the topology where false is the correct answer.
|
|
114
|
+
if (process.env.RENDER || process.env.RENDER_EXTERNAL_URL) {
|
|
115
|
+
console.warn(
|
|
116
|
+
'⚠️ TRUST_PROXY is unset and this looks like a proxied deployment: assuming NO proxy. ' +
|
|
117
|
+
'Set TRUST_PROXY=1 (single hop) or the real hop count, or per-IP rate limiting will be global.'
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
const lower = value.toLowerCase();
|
|
123
|
+
if (['false', '0', 'off', 'no'].includes(lower)) return false;
|
|
124
|
+
if (lower === 'true') return true;
|
|
125
|
+
if (/^\d+$/.test(value)) return parseInt(value, 10);
|
|
126
|
+
// Express also accepts a list of addresses/CIDRs plus the keywords loopback/linklocal/uniquelocal -
|
|
127
|
+
// the form a real multi-hop deployment needs - so that is passed through. A guess here is not
|
|
128
|
+
// harmless: a typo that express cannot parse would fall back to trusting nothing (or everything),
|
|
129
|
+
// silently changing limiter scope, which is the class of bug this whole block exists to prevent.
|
|
130
|
+
const tokens = value.split(',').map((t) => t.trim()).filter(Boolean);
|
|
131
|
+
const valid = tokens.length > 0 && tokens.every((t) =>
|
|
132
|
+
['loopback', 'linklocal', 'uniquelocal'].includes(t.toLowerCase()) ||
|
|
133
|
+
/^[0-9a-fA-F:.]+(\/\d{1,3})?$/.test(t));
|
|
134
|
+
if (valid) return tokens;
|
|
135
|
+
throw new Error(
|
|
136
|
+
`TRUST_PROXY="${value}" is not a value express can use. Expected false/true, a hop count, or a ` +
|
|
137
|
+
'comma-separated list of IPs/CIDRs (loopback, linklocal, uniquelocal are also accepted). ' +
|
|
138
|
+
'Refusing to start rather than silently changing rate-limiter scope.'
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
app.set('trust proxy', resolveTrustProxy(process.env.TRUST_PROXY));
|
|
38
143
|
// No X-Powered-By: Express - free, standard hardening (avoids handing a
|
|
39
144
|
// public-facing service's framework fingerprint to every caller for no
|
|
40
145
|
// benefit).
|
|
@@ -132,8 +237,10 @@ const seams = {
|
|
|
132
237
|
// (See the callers below: they never cache a client built from a
|
|
133
238
|
// non-default resolver's key, so a decrypted per-tenant secret never
|
|
134
239
|
// outlives the one request it was resolved for.)
|
|
135
|
-
resolveProviderKey: (scope, provider) =>
|
|
136
|
-
|
|
240
|
+
resolveProviderKey: (scope, provider) => {
|
|
241
|
+
const key = providers.envKey(provider);
|
|
242
|
+
return (key ? process.env[key] : null) || null;
|
|
243
|
+
},
|
|
137
244
|
|
|
138
245
|
// Passed straight through as express-rate-limit's own `keyGenerator`.
|
|
139
246
|
// Default: undefined, so express-rate-limit's own per-IP default
|
|
@@ -261,25 +368,31 @@ app.use('/v1', rateLimiter, requireInternalKey, express.json({ limit: process.en
|
|
|
261
368
|
// tenant), every call below builds a fresh client instead of caching
|
|
262
369
|
// one - a decrypted secret must never outlive the one request it was
|
|
263
370
|
// resolved for.
|
|
264
|
-
|
|
265
|
-
|
|
371
|
+
// One cached client per provider for the default (unscoped) resolver. A
|
|
372
|
+
// scoped resolver may return a per-tenant decrypted secret, so those clients
|
|
373
|
+
// are deliberately NOT cached - a decrypted key must never outlive the request
|
|
374
|
+
// it was resolved for.
|
|
375
|
+
const defaultClients = new Map();
|
|
266
376
|
|
|
267
|
-
async function
|
|
268
|
-
const
|
|
377
|
+
async function getProviderClient(scope, provider) {
|
|
378
|
+
const mod = providers.get(provider);
|
|
379
|
+
if (!mod) throw Object.assign(new Error(`unknown provider: ${provider}`), { status: 500 });
|
|
380
|
+
const key = await seams.resolveProviderKey(scope, provider);
|
|
269
381
|
if (scope == null) {
|
|
270
|
-
if (!
|
|
271
|
-
return
|
|
382
|
+
if (!defaultClients.has(provider)) defaultClients.set(provider, mod.buildClient(key));
|
|
383
|
+
return defaultClients.get(provider);
|
|
272
384
|
}
|
|
273
|
-
return
|
|
385
|
+
return mod.buildClient(key);
|
|
274
386
|
}
|
|
275
387
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
388
|
+
const getAnthropicClient = (scope) => getProviderClient(scope, 'anthropic');
|
|
389
|
+
const getOpenAiClient = (scope) => getProviderClient(scope, 'openai');
|
|
390
|
+
|
|
391
|
+
// Thin wrappers kept for the call sites that genuinely mean "this one
|
|
392
|
+
// provider" (cascade confidence, the grader). Everything else asks the
|
|
393
|
+
// registry: providerForModel() is the single answer to "who serves this?".
|
|
394
|
+
function providerForModel(model) {
|
|
395
|
+
return providers.detectProvider(model);
|
|
283
396
|
}
|
|
284
397
|
|
|
285
398
|
function isModelAnthropic(model) {
|
|
@@ -333,6 +446,7 @@ app.get('/stats', requireInternalKey, readEndpointLimiter, async (req, res) => {
|
|
|
333
446
|
const totalCostUsd = recent.reduce((sum, r) => sum + (r.cost_usd || 0), 0);
|
|
334
447
|
const exactHits = recent.filter((r) => r.cache_hit && r.cache_type !== 'semantic').length;
|
|
335
448
|
const semanticHits = recent.filter((r) => r.cache_hit && r.cache_type === 'semantic').length;
|
|
449
|
+
const savedUsd = metrics.computeSavings(recent).total;
|
|
336
450
|
res.json({
|
|
337
451
|
sample_size: recent.length,
|
|
338
452
|
cache_hit_rate: {
|
|
@@ -341,6 +455,7 @@ app.get('/stats', requireInternalKey, readEndpointLimiter, async (req, res) => {
|
|
|
341
455
|
combined: recent.length ? (exactHits + semanticHits) / recent.length : 0
|
|
342
456
|
},
|
|
343
457
|
total_cost_usd: totalCostUsd,
|
|
458
|
+
saved_usd: savedUsd,
|
|
344
459
|
by_provider: byProvider,
|
|
345
460
|
// Internal routing configuration - which providers have a key
|
|
346
461
|
// configured, which virtual-model tiers exist, and which strategy picks
|
|
@@ -457,17 +572,75 @@ function streamCachedReplay(res, entry, cacheType) {
|
|
|
457
572
|
// against more than one provider in the same request. Throws an error
|
|
458
573
|
// with `.status` set so failover.isRetryableError() can decide whether
|
|
459
574
|
// it's worth trying the next candidate.
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
575
|
+
// `options` is provider-specific dispatch hints (cascade routing, step 34).
|
|
576
|
+
// It reaches only providers/openai.js today (requestLogprobs); Anthropic
|
|
577
|
+
// ignores it. Kept backward-compatible - existing callers pass no options.
|
|
578
|
+
async function dispatchToProvider(scope, provider, payload, options = {}) {
|
|
579
|
+
const mod = providers.get(provider);
|
|
580
|
+
if (!mod) throw Object.assign(new Error(`unknown provider: ${provider}`), { status: 500 });
|
|
581
|
+
if (!(await seams.resolveProviderKey(scope, provider))) {
|
|
582
|
+
// Names the variable the provider actually needs - the message is built
|
|
583
|
+
// from the registry, so it cannot drift from resolveProviderKey above.
|
|
584
|
+
throw Object.assign(new Error(`${providers.envKey(provider)} not configured`), { status: 500 });
|
|
466
585
|
}
|
|
467
|
-
|
|
468
|
-
|
|
586
|
+
return mod.chat(await getProviderClient(scope, provider), payload, options);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Step 34 (cascade routing): per-provider confidence estimation. OpenAI's
|
|
590
|
+
// path is pure (cascade.openaiLogprobConfidence over the logprobs the
|
|
591
|
+
// dispatch asked for). Anthropic has no native logprobs, so its confidence
|
|
592
|
+
// comes from a grader model - one bounded extra request to a small/cheap
|
|
593
|
+
// model asking "does this response fully and confidently answer the
|
|
594
|
+
// question? reply with a number 0-1" (Claude's recommendation over
|
|
595
|
+
// self-consistency, which multiplies cost 2-3x on every cheap-tier dispatch
|
|
596
|
+
// and works directly against cascade's own cheap-first reason to exist).
|
|
597
|
+
//
|
|
598
|
+
// The grader is opt-in (CASCADE_GRADER_MODEL): unset means Anthropic
|
|
599
|
+
// candidates return null confidence - fail-open, no escalation, the same
|
|
600
|
+
// "insufficient data must never escalate" discipline as everywhere else.
|
|
601
|
+
// The grader's own provider call is recorded like any other real dispatch so
|
|
602
|
+
// its cost is never invisible.
|
|
603
|
+
function buildConfidenceEstimator(scope, payload, requestedModel, traceId) {
|
|
604
|
+
return async (result) => {
|
|
605
|
+
if (!result) return null;
|
|
606
|
+
if (result.provider === 'openai') return cascade.openaiLogprobConfidence(result);
|
|
607
|
+
if (result.provider === 'anthropic') return graderConfidence(scope, payload, requestedModel, result, traceId);
|
|
608
|
+
return null;
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async function graderConfidence(scope, payload, requestedModel, result, traceId) {
|
|
613
|
+
const graderModel = process.env.CASCADE_GRADER_MODEL;
|
|
614
|
+
if (!graderModel) return null; // no grader configured -> no signal -> fail open
|
|
615
|
+
// Any chat-capable provider can grade (it is asked to reply with a number),
|
|
616
|
+
// so this asks the registry rather than hardcoding two names.
|
|
617
|
+
const graderProvider = providerForModel(graderModel);
|
|
618
|
+
if (!graderProvider) return null;
|
|
619
|
+
try {
|
|
620
|
+
const grade = await dispatchToProvider(scope, graderProvider, {
|
|
621
|
+
model: graderModel,
|
|
622
|
+
messages: cascade.buildGraderMessages(payload.messages, result.content),
|
|
623
|
+
temperature: 0,
|
|
624
|
+
max_tokens: 8 // the grader only needs to emit a single 0-1 number
|
|
625
|
+
});
|
|
626
|
+
metrics.record(scope, {
|
|
627
|
+
provider: grade.provider,
|
|
628
|
+
model: grade.model,
|
|
629
|
+
requested_model: requestedModel,
|
|
630
|
+
cache_hit: false,
|
|
631
|
+
quality_score: 1.0,
|
|
632
|
+
trace_id: traceId,
|
|
633
|
+
latency_ms: grade.latency_ms,
|
|
634
|
+
cost_usd: grade.cost_usd
|
|
635
|
+
});
|
|
636
|
+
return cascade.parseGraderScore(grade.content);
|
|
637
|
+
} catch (err) {
|
|
638
|
+
// A grader failure (missing key, provider down) must never fail the
|
|
639
|
+
// request being graded - treat it as "no confidence signal" and accept
|
|
640
|
+
// the candidate's answer (fail-open).
|
|
641
|
+
console.warn('⚠️ Cascade grader failed, accepting candidate without a confidence signal:', err.message);
|
|
642
|
+
return null;
|
|
469
643
|
}
|
|
470
|
-
return openaiProvider.chat(await getOpenAiClient(scope), payload);
|
|
471
644
|
}
|
|
472
645
|
|
|
473
646
|
// The real streaming dispatch path: an actual cache miss, forwarded
|
|
@@ -480,18 +653,15 @@ async function dispatchToProvider(scope, provider, payload) {
|
|
|
480
653
|
// about which model answered - a materially harder problem than the
|
|
481
654
|
// non-streaming case, left as a documented gap rather than shipped
|
|
482
655
|
// half-working (see ROADMAP.md).
|
|
483
|
-
async function handleStreamingDispatch(req, res, payload, requestedModel, routingDecision) {
|
|
656
|
+
async function handleStreamingDispatch(req, res, payload, requestedModel, routingDecision, traceId) {
|
|
484
657
|
const scope = req.scope;
|
|
485
|
-
|
|
486
|
-
if (
|
|
487
|
-
providerName = 'anthropic';
|
|
488
|
-
if (!(await seams.resolveProviderKey(scope, 'anthropic'))) return res.status(500).json({ error: 'ANTHROPIC_API_KEY not configured' });
|
|
489
|
-
} else if (isModelOpenAi(payload.model)) {
|
|
490
|
-
providerName = 'openai';
|
|
491
|
-
if (!(await seams.resolveProviderKey(scope, 'openai'))) return res.status(500).json({ error: 'OPENAI_API_KEY not configured' });
|
|
492
|
-
} else {
|
|
658
|
+
const providerName = providerForModel(payload.model);
|
|
659
|
+
if (!providerName) {
|
|
493
660
|
return res.status(400).json({ error: `Unsupported model: ${payload.model}` });
|
|
494
661
|
}
|
|
662
|
+
if (!(await seams.resolveProviderKey(scope, providerName))) {
|
|
663
|
+
return res.status(500).json({ error: `${providers.envKey(providerName)} not configured` });
|
|
664
|
+
}
|
|
495
665
|
|
|
496
666
|
streaming.startSse(res);
|
|
497
667
|
const id = streaming.genId();
|
|
@@ -504,8 +674,8 @@ async function handleStreamingDispatch(req, res, payload, requestedModel, routin
|
|
|
504
674
|
|
|
505
675
|
let result;
|
|
506
676
|
try {
|
|
507
|
-
const client =
|
|
508
|
-
const chatStreamFn = providerName
|
|
677
|
+
const client = await getProviderClient(scope, providerName);
|
|
678
|
+
const chatStreamFn = providers.get(providerName).chatStream;
|
|
509
679
|
result = await chatStreamFn(client, payload, {
|
|
510
680
|
signal: controller.signal,
|
|
511
681
|
onDelta: (text) => res.write(streaming.deltaChunk({ id, model: payload.model, content: text }))
|
|
@@ -524,6 +694,8 @@ async function handleStreamingDispatch(req, res, payload, requestedModel, routin
|
|
|
524
694
|
model: payload.model,
|
|
525
695
|
requested_model: requestedModel,
|
|
526
696
|
cache_hit: false,
|
|
697
|
+
quality_score: 0.0,
|
|
698
|
+
trace_id: traceId,
|
|
527
699
|
error: err.message,
|
|
528
700
|
error_type: metrics.classifyErrorType(err.message)
|
|
529
701
|
});
|
|
@@ -549,12 +721,30 @@ async function handleStreamingDispatch(req, res, payload, requestedModel, routin
|
|
|
549
721
|
model: result.model,
|
|
550
722
|
requested_model: requestedModel,
|
|
551
723
|
cache_hit: false,
|
|
724
|
+
quality_score: 1.0,
|
|
725
|
+
trace_id: traceId,
|
|
552
726
|
latency_ms: result.latency_ms,
|
|
553
727
|
cost_usd: result.cost_usd
|
|
554
728
|
});
|
|
555
729
|
}
|
|
556
730
|
|
|
731
|
+
// The one request-scoped correlation id (step 36.1): generated once per
|
|
732
|
+
// incoming request, returned as X-Cachegate-Trace-Id, and threaded through
|
|
733
|
+
// every metrics.record() this request makes - so a support conversation or a
|
|
734
|
+
// customer's own log line can reference the exact id that ties together every
|
|
735
|
+
// metrics row (cache hit, each dispatch attempt, a cascade's cheap+escalated
|
|
736
|
+
// pair, a coalesced joiner+leader pair) this request produced. Same generator
|
|
737
|
+
// family as streaming.genId() (crypto.randomBytes hex), no second scheme.
|
|
557
738
|
app.post('/v1/chat/completions', async (req, res) => {
|
|
739
|
+
const traceId = crypto.randomBytes(16).toString('hex');
|
|
740
|
+
res.setHeader('X-Cachegate-Trace-Id', traceId);
|
|
741
|
+
const modelName = (req.body && req.body.model) || 'chat.completion';
|
|
742
|
+
await tracing.withRootSpan(modelName, { trace_id: traceId }, () =>
|
|
743
|
+
handleCompletion(req, res, traceId)
|
|
744
|
+
);
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
async function handleCompletion(req, res, traceId) {
|
|
558
748
|
const payload = req.body;
|
|
559
749
|
const scope = req.scope; // set by requireInternalKey/seams.authenticate - null unless configured
|
|
560
750
|
|
|
@@ -601,13 +791,51 @@ app.post('/v1/chat/completions', async (req, res) => {
|
|
|
601
791
|
payload.model = routingDecision.model;
|
|
602
792
|
}
|
|
603
793
|
|
|
794
|
+
// Step 25 (joint wiring): PII redaction + injection policy run BEFORE
|
|
795
|
+
// the cache lookup, so redacted content is what gets cached (never raw
|
|
796
|
+
// PII) and a blocked request never reaches a provider or a cache write.
|
|
797
|
+
// Both modules are gated off by default, so this is a no-op unless the
|
|
798
|
+
// deployment opts in.
|
|
799
|
+
if (pii.isEnabled()) {
|
|
800
|
+
payload.messages = redactMessages(payload.messages);
|
|
801
|
+
}
|
|
802
|
+
const policy = guardrails.evaluate(payload.messages);
|
|
803
|
+
if (policy.decision === 'block') {
|
|
804
|
+
return res.status(403).json({ error: 'Request blocked by content policy.' });
|
|
805
|
+
}
|
|
806
|
+
if (policy.decision !== 'allow') {
|
|
807
|
+
// flag/log: pass through but record the detection. Console for now - a
|
|
808
|
+
// dedicated metric column is a possible follow-up (same as the
|
|
809
|
+
// coalesced column step 24 added).
|
|
810
|
+
console.warn(`[guardrails] ${policy.decision}: ${policy.reasons.join(', ')}`);
|
|
811
|
+
}
|
|
812
|
+
|
|
604
813
|
// 1. Try the exact-match cache first - free, zero-risk, checked
|
|
605
814
|
// before anything else (keyed on the resolved concrete model, so a
|
|
606
815
|
// routed request and a direct request for the same concrete model
|
|
607
816
|
// share the same cache entries). A hit is served the same way
|
|
608
817
|
// whether or not the caller asked for stream:true - see
|
|
609
818
|
// streamCachedReplay() for the streaming case.
|
|
610
|
-
const cached = await cache.get(scope, payload);
|
|
819
|
+
const cached = await tracing.withSpan('cache.exact', { trace_id: traceId }, () => cache.get(scope, payload));
|
|
820
|
+
|
|
821
|
+
// R4 (review 2026-09-10): the measurement that decides whether URL/email literal slotting earns its
|
|
822
|
+
// keep. One line per request carrying (a) whether the exact cache hit and (b) which slotting rules
|
|
823
|
+
// fired, so a week of logs splits hit rate by slotting instead of arguing about it. slottingFlags
|
|
824
|
+
// also reports the GATED rules (date, number), which is what makes the number/date decision priceable
|
|
825
|
+
// without turning it on - that gate stays off.
|
|
826
|
+
//
|
|
827
|
+
// Only this path: slotting lives in the exact-cache key. The semantic path embeds the raw prompt, so
|
|
828
|
+
// slotting does not touch it and a line there would measure nothing.
|
|
829
|
+
//
|
|
830
|
+
// Honest limit, stated here rather than discovered later: this is the hit rate AMONG requests that
|
|
831
|
+
// slot, not the rate they would have had WITHOUT slotting. The true counterfactual needs a second
|
|
832
|
+
// lookup per request, which is a behaviour change - and this measurement is not allowed to change the
|
|
833
|
+
// thing it measures.
|
|
834
|
+
if (process.env.CACHE_SLOT_STATS !== '0') {
|
|
835
|
+
const sf = cache.slottingFlags(payload.messages);
|
|
836
|
+
console.log(`[cacheslot] hit=${cached ? 1 : 0} model=${payload.model} url=${sf.url} email=${sf.email} date=${sf.date} number=${sf.number} numbers_gate=${sf.numbers_gate}`);
|
|
837
|
+
}
|
|
838
|
+
|
|
611
839
|
if (cached) {
|
|
612
840
|
metrics.record(scope, {
|
|
613
841
|
provider: cached.provider,
|
|
@@ -615,6 +843,7 @@ app.post('/v1/chat/completions', async (req, res) => {
|
|
|
615
843
|
requested_model: requestedModel,
|
|
616
844
|
cache_hit: true,
|
|
617
845
|
cache_type: 'exact',
|
|
846
|
+
trace_id: traceId,
|
|
618
847
|
latency_ms: 0,
|
|
619
848
|
cost_usd: 0
|
|
620
849
|
});
|
|
@@ -642,7 +871,7 @@ app.post('/v1/chat/completions', async (req, res) => {
|
|
|
642
871
|
// prompt, not an identical one). This costs one embedding call
|
|
643
872
|
// whether or not it finds anything; see semanticCache.js for why
|
|
644
873
|
// that's a deliberate tradeoff, not overhead to optimize away.
|
|
645
|
-
const semanticMatch = await semanticCache.findMatch(scope, payload);
|
|
874
|
+
const semanticMatch = await tracing.withSpan('cache.semantic', { trace_id: traceId }, () => semanticCache.findMatch(scope, payload));
|
|
646
875
|
if (semanticMatch) {
|
|
647
876
|
const hit = semanticMatch.entry;
|
|
648
877
|
metrics.record(scope, {
|
|
@@ -652,6 +881,7 @@ app.post('/v1/chat/completions', async (req, res) => {
|
|
|
652
881
|
cache_hit: true,
|
|
653
882
|
cache_type: 'semantic',
|
|
654
883
|
semantic_similarity: semanticMatch.similarity,
|
|
884
|
+
trace_id: traceId,
|
|
655
885
|
latency_ms: 0,
|
|
656
886
|
cost_usd: 0
|
|
657
887
|
});
|
|
@@ -682,76 +912,220 @@ app.post('/v1/chat/completions', async (req, res) => {
|
|
|
682
912
|
// so the two need different error-reporting strategies (see
|
|
683
913
|
// handleStreamingDispatch's error frame vs. this path's 502 JSON).
|
|
684
914
|
if (wantsStream) {
|
|
685
|
-
return handleStreamingDispatch(req, res, payload, requestedModel, routingDecision);
|
|
915
|
+
return handleStreamingDispatch(req, res, payload, requestedModel, routingDecision, traceId);
|
|
686
916
|
}
|
|
687
917
|
|
|
688
918
|
try {
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
(
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
919
|
+
// 2.5. Request coalescing (step 24): identical concurrent cache-miss
|
|
920
|
+
// requests share ONE upstream dispatch (single-flight). Keyed on the
|
|
921
|
+
// exact cache key, so only byte-identical requests coalesce. The
|
|
922
|
+
// callback below is the leader's dispatch - provider call, cache
|
|
923
|
+
// writes, and the leader's cache-miss metric - and a joiner awaits
|
|
924
|
+
// that same promise instead of dispatching again.
|
|
925
|
+
// The coalescing span carries a leader/joiner role attribute so a trace
|
|
926
|
+
// visibly distinguishes the request that actually dispatched (leader)
|
|
927
|
+
// from the ones that shared its upstream call (joiners). The role is only
|
|
928
|
+
// known after joinOrRun settles, so the attribute is set before end().
|
|
929
|
+
const coalesceSpan = tracing.startSpan('coalescing', { trace_id: traceId });
|
|
930
|
+
let joined;
|
|
931
|
+
try {
|
|
932
|
+
joined = await coalescing.joinOrRun(
|
|
933
|
+
cache.buildCacheKey(scope, payload),
|
|
934
|
+
async () => {
|
|
935
|
+
let result;
|
|
936
|
+
let failedOver = false;
|
|
937
|
+
let cascaded = false;
|
|
938
|
+
|
|
939
|
+
if (routingDecision) {
|
|
940
|
+
// Virtual model: try the ranked candidates in order (router.js's
|
|
941
|
+
// own health/strategy scoring already produced this order),
|
|
942
|
+
// falling over to the next one when a provider fails in a way
|
|
943
|
+
// that isn't the REQUEST's own fault - see
|
|
944
|
+
// failover.isRetryableError for exactly what that means. Every
|
|
945
|
+
// failed attempt is recorded on the dashboard the same way a
|
|
946
|
+
// non-failed-over error would be (below), so failover keeps the
|
|
947
|
+
// request succeeding without hiding the underlying provider
|
|
948
|
+
// problem from the Provider alerts table.
|
|
949
|
+
//
|
|
950
|
+
// Step 34 (cascade routing): when CASCADE_ENABLED, the same walk
|
|
951
|
+
// ALSO escalates on a successful-but-low-confidence response -
|
|
952
|
+
// orthogonal to failover (which retries on ERROR). The default
|
|
953
|
+
// (cascade off) stays on failover.dispatchWithFailover, byte-
|
|
954
|
+
// identical to before cascade existed.
|
|
955
|
+
const onAttemptFailed = (candidate, err) => metrics.record(scope, {
|
|
956
|
+
provider: candidate.provider,
|
|
957
|
+
model: candidate.model,
|
|
958
|
+
requested_model: requestedModel,
|
|
959
|
+
cache_hit: false,
|
|
960
|
+
quality_score: 0.0,
|
|
961
|
+
trace_id: traceId,
|
|
962
|
+
error: err.message,
|
|
963
|
+
error_type: metrics.classifyErrorType(err.message)
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
// Each provider dispatch attempt - a failover retry or a cascade
|
|
967
|
+
// escalation - is its own child span, not folded into one, so a
|
|
968
|
+
// trace visibly shows "escalate to a bigger model" as a distinct
|
|
969
|
+
// step, not just `cascaded: true` after the fact.
|
|
970
|
+
const dispatchAttempt = (candidate) => tracing.withSpan(
|
|
971
|
+
'dispatch',
|
|
972
|
+
{ provider: candidate.provider, model: candidate.model, trace_id: traceId },
|
|
973
|
+
() => dispatchToProvider(
|
|
974
|
+
scope,
|
|
975
|
+
candidate.provider,
|
|
976
|
+
{ ...payload, model: candidate.model },
|
|
977
|
+
{ requestLogprobs: cascade.isEnabled() && candidate.provider === 'openai' }
|
|
978
|
+
)
|
|
979
|
+
);
|
|
980
|
+
|
|
981
|
+
let attempt;
|
|
982
|
+
if (cascade.isEnabled()) {
|
|
983
|
+
attempt = await cascade.tryWithCascade(
|
|
984
|
+
routingDecision.rankedCandidates,
|
|
985
|
+
dispatchAttempt,
|
|
986
|
+
buildConfidenceEstimator(scope, payload, requestedModel, traceId),
|
|
987
|
+
{
|
|
988
|
+
threshold: cascade.threshold(),
|
|
989
|
+
onAttemptFailed,
|
|
990
|
+
onEscalated: (fromCandidate, toCandidate, cheapResult) => {
|
|
991
|
+
// The cheap answer is rejected (that's cascade's point),
|
|
992
|
+
// but its dispatch was a real provider call with real cost
|
|
993
|
+
// - record it so the spend is never invisible. It is NOT
|
|
994
|
+
// marked `cascaded`: that flag belongs to the dispatch we
|
|
995
|
+
// escalated TO (the final record below).
|
|
996
|
+
//
|
|
997
|
+
// quality_score is ALWAYS 0.5 here, never 1.0 - caught in
|
|
998
|
+
// review: the original version scored it 1.0 whenever no
|
|
999
|
+
// earlier candidate had failed over, meaning a candidate
|
|
1000
|
+
// whose answer was just rejected for low confidence still
|
|
1001
|
+
// got a PERFECT quality score. That directly corrupts the
|
|
1002
|
+
// exact signal Step 33's shed logic depends on: a provider
|
|
1003
|
+
// that's frequently escalated past due to low confidence
|
|
1004
|
+
// would show a misleadingly perfect avgQualityScore instead
|
|
1005
|
+
// of the "this one needs a second look" signal it should.
|
|
1006
|
+
// A low-confidence rejection alone already disqualifies a
|
|
1007
|
+
// perfect score, regardless of whether failover ALSO
|
|
1008
|
+
// happened earlier in the same walk.
|
|
1009
|
+
metrics.record(scope, {
|
|
1010
|
+
provider: cheapResult.provider,
|
|
1011
|
+
model: cheapResult.model,
|
|
1012
|
+
requested_model: requestedModel,
|
|
1013
|
+
cache_hit: false,
|
|
1014
|
+
quality_score: 0.5,
|
|
1015
|
+
trace_id: traceId,
|
|
1016
|
+
latency_ms: cheapResult.latency_ms,
|
|
1017
|
+
cost_usd: cheapResult.cost_usd
|
|
1018
|
+
});
|
|
1019
|
+
console.warn(`⚠️ Model router cascade: ${fromCandidate.provider}/${fromCandidate.model} answered with low confidence, escalating to ${toCandidate.provider}/${toCandidate.model}`);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
);
|
|
1023
|
+
cascaded = attempt.cascaded;
|
|
1024
|
+
} else {
|
|
1025
|
+
attempt = await failover.dispatchWithFailover(
|
|
1026
|
+
routingDecision.rankedCandidates,
|
|
1027
|
+
dispatchAttempt,
|
|
1028
|
+
onAttemptFailed
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
result = attempt.result;
|
|
1032
|
+
failedOver = attempt.failedOver !== undefined ? attempt.failedOver : attempt.attempts > 1;
|
|
1033
|
+
payload.model = result.model; // the candidate that actually served it, if failover/cascade moved past the first choice
|
|
1034
|
+
if (failedOver) {
|
|
1035
|
+
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})`);
|
|
1036
|
+
}
|
|
1037
|
+
} else if (providerForModel(payload.model)) {
|
|
1038
|
+
const directProvider = providerForModel(payload.model);
|
|
1039
|
+
if (!(await seams.resolveProviderKey(scope, directProvider))) {
|
|
1040
|
+
throw httpError(500, `${providers.envKey(directProvider)} not configured`, { skipMetric: true });
|
|
1041
|
+
}
|
|
1042
|
+
result = await providers.get(directProvider)
|
|
1043
|
+
.chat(await getProviderClient(scope, directProvider), payload, {
|
|
1044
|
+
requestLogprobs: cascade.isEnabled() && directProvider === 'openai'
|
|
1045
|
+
});
|
|
1046
|
+
} else {
|
|
1047
|
+
throw httpError(400, `Unsupported model: ${payload.model}`, { skipMetric: true });
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// Store in both caches - exact-match for identical future
|
|
1051
|
+
// requests, semantic for near-duplicate ones. Both no-op quietly if
|
|
1052
|
+
// their prerequisites (Redis / OPENAI_API_KEY) aren't configured.
|
|
1053
|
+
// Cascade (step 34): only the response that PASSES confidence gets
|
|
1054
|
+
// cached. A rejected low-confidence answer was already escalated
|
|
1055
|
+
// away inside tryWithCascade, so `result` here is always the final
|
|
1056
|
+
// accepted response - never the cheap one we threw away.
|
|
1057
|
+
await cache.set(scope, payload, result);
|
|
1058
|
+
await semanticCache.store(scope, payload, result);
|
|
1059
|
+
|
|
1060
|
+
metrics.record(scope, {
|
|
1061
|
+
provider: result.provider,
|
|
1062
|
+
model: result.model,
|
|
708
1063
|
requested_model: requestedModel,
|
|
709
1064
|
cache_hit: false,
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
1065
|
+
quality_score: failedOver ? 0.5 : 1.0,
|
|
1066
|
+
...(cascaded ? { cascaded: true } : {}),
|
|
1067
|
+
trace_id: traceId,
|
|
1068
|
+
latency_ms: result.latency_ms,
|
|
1069
|
+
cost_usd: result.cost_usd
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
return { result, failedOver, cascaded };
|
|
1073
|
+
},
|
|
1074
|
+
traceId
|
|
713
1075
|
);
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
} else if (isModelAnthropic(payload.model)) {
|
|
721
|
-
if (!(await seams.resolveProviderKey(scope, 'anthropic'))) {
|
|
722
|
-
return res.status(500).json({ error: 'ANTHROPIC_API_KEY not configured' });
|
|
723
|
-
}
|
|
724
|
-
result = await anthropicProvider.chat(await getAnthropicClient(scope), payload);
|
|
725
|
-
} else if (isModelOpenAi(payload.model)) {
|
|
726
|
-
if (!(await seams.resolveProviderKey(scope, 'openai'))) {
|
|
727
|
-
return res.status(500).json({ error: 'OPENAI_API_KEY not configured' });
|
|
1076
|
+
} finally {
|
|
1077
|
+
if (joined) {
|
|
1078
|
+
coalesceSpan.setAttribute('coalescing.role', joined.coalesced ? 'joiner' : 'leader');
|
|
1079
|
+
if (joined.coalesced && joined.joinedTraceId) {
|
|
1080
|
+
coalesceSpan.setAttribute('coalescing.joined_trace_id', joined.joinedTraceId);
|
|
1081
|
+
}
|
|
728
1082
|
}
|
|
729
|
-
|
|
730
|
-
} else {
|
|
731
|
-
return res.status(400).json({ error: `Unsupported model: ${payload.model}` });
|
|
1083
|
+
coalesceSpan.end();
|
|
732
1084
|
}
|
|
733
1085
|
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
// their prerequisites (Redis / OPENAI_API_KEY) aren't configured.
|
|
737
|
-
await cache.set(scope, payload, result);
|
|
738
|
-
await semanticCache.store(scope, payload, result);
|
|
1086
|
+
const { result: dispatchOutcome, coalesced: wasCoalesced, joinedTraceId } = joined;
|
|
1087
|
+
const { result, failedOver, cascaded } = dispatchOutcome;
|
|
739
1088
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
1089
|
+
if (wasCoalesced) {
|
|
1090
|
+
// Joiner: shared the leader's upstream call - record it distinctly
|
|
1091
|
+
// (coalesced: true, zero NEW cost) so coalescing is measurable, not
|
|
1092
|
+
// just asserted. The leader's record above is the single source of
|
|
1093
|
+
// cost for the one upstream call that actually happened.
|
|
1094
|
+
//
|
|
1095
|
+
// quality_score is INHERITED from the leader (failedOver came back
|
|
1096
|
+
// on the same shared dispatchOutcome), not omitted like a cache hit
|
|
1097
|
+
// - a joiner isn't "no independent dispatch happened" in the same
|
|
1098
|
+
// sense a cache hit is; it received the exact same result as the
|
|
1099
|
+
// leader, over the exact same failedOver-or-not path, so its
|
|
1100
|
+
// quality signal is identical, not absent. Omitting it would
|
|
1101
|
+
// systematically under-sample avgQualityScore precisely for the
|
|
1102
|
+
// busiest, most-coalesced request shapes - the opposite of what a
|
|
1103
|
+
// signal meant to feed future routing decisions should do. `cascaded`
|
|
1104
|
+
// inherits for the exact same reason: a joiner received the
|
|
1105
|
+
// escalated result, so it must be visible as escalated too.
|
|
1106
|
+
metrics.record(scope, {
|
|
1107
|
+
provider: result.provider,
|
|
1108
|
+
model: result.model,
|
|
1109
|
+
requested_model: requestedModel,
|
|
1110
|
+
cache_hit: false,
|
|
1111
|
+
coalesced: true,
|
|
1112
|
+
quality_score: failedOver ? 0.5 : 1.0,
|
|
1113
|
+
...(cascaded ? { cascaded: true } : {}),
|
|
1114
|
+
trace_id: traceId,
|
|
1115
|
+
...(joinedTraceId ? { joined_trace_id: joinedTraceId } : {}),
|
|
1116
|
+
latency_ms: result.latency_ms,
|
|
1117
|
+
cost_usd: 0
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
748
1120
|
|
|
749
1121
|
res.json({
|
|
750
1122
|
cached: false,
|
|
1123
|
+
coalesced: wasCoalesced ? true : undefined,
|
|
751
1124
|
provider: result.provider,
|
|
752
1125
|
model: result.model,
|
|
753
1126
|
routed_from: routingDecision ? requestedModel : undefined,
|
|
754
1127
|
failover: failedOver ? true : undefined,
|
|
1128
|
+
cascaded: cascaded ? true : undefined,
|
|
755
1129
|
latency_ms: result.latency_ms,
|
|
756
1130
|
usage: result.usage,
|
|
757
1131
|
cost_usd: result.cost_usd,
|
|
@@ -765,23 +1139,44 @@ app.post('/v1/chat/completions', async (req, res) => {
|
|
|
765
1139
|
});
|
|
766
1140
|
} catch (err) {
|
|
767
1141
|
console.error('❌ Model router error:', err.message);
|
|
768
|
-
if (!routingDecision) {
|
|
1142
|
+
if (!routingDecision && !err.skipMetric) {
|
|
769
1143
|
// Virtual-model attempts already record one metrics entry PER
|
|
770
1144
|
// candidate as each fails (see the onAttemptFailed callback
|
|
771
1145
|
// above), including whichever one was last - recording again
|
|
772
|
-
// here would double-count it.
|
|
1146
|
+
// here would double-count it. skipMetric is the OTHER exclusion:
|
|
1147
|
+
// a pre-dispatch validation failure (missing key, unsupported
|
|
1148
|
+
// model - see httpError()'s own comment) never reached a
|
|
1149
|
+
// provider at all, so recording it here would be new behavior,
|
|
1150
|
+
// not a restoration - the original inline `return res.status(...)`
|
|
1151
|
+
// code never touched metrics for these either.
|
|
773
1152
|
metrics.record(scope, {
|
|
774
|
-
|
|
1153
|
+
// `|| 'openai'` keeps the pre-registry fallback for a model nothing
|
|
1154
|
+
// claims, so this metric row is not a behavior change.
|
|
1155
|
+
provider: providerForModel(payload.model) || 'openai',
|
|
775
1156
|
model: payload.model,
|
|
776
1157
|
requested_model: requestedModel,
|
|
777
1158
|
cache_hit: false,
|
|
1159
|
+
quality_score: 0.0,
|
|
1160
|
+
trace_id: traceId,
|
|
778
1161
|
error: err.message,
|
|
779
1162
|
error_type: metrics.classifyErrorType(err.message)
|
|
780
1163
|
});
|
|
781
1164
|
}
|
|
782
|
-
|
|
1165
|
+
// err.status is only honored on the explicit-model path (where it
|
|
1166
|
+
// can ONLY come from this file's own httpError() calls above - a
|
|
1167
|
+
// deliberate 500/400 for a config/validation failure). The virtual-
|
|
1168
|
+
// model/failover path always falls back to 502 regardless of
|
|
1169
|
+
// err.status: dispatchToProvider() sets .status=500 on ITS OWN
|
|
1170
|
+
// thrown errors too, but only for failover.isRetryableError()'s
|
|
1171
|
+
// internal retry decision - that was never meant to reach the
|
|
1172
|
+
// client as the final status once every candidate is exhausted
|
|
1173
|
+
// (see the dedicated test for this exact contract: exhausted
|
|
1174
|
+
// failover -> 502, always, whatever the last candidate's own
|
|
1175
|
+
// error looked like).
|
|
1176
|
+
const status = !routingDecision && err.status ? err.status : 502;
|
|
1177
|
+
res.status(status).json({ error: err.message });
|
|
783
1178
|
}
|
|
784
|
-
}
|
|
1179
|
+
}
|
|
785
1180
|
|
|
786
1181
|
// Step 14 (ROADMAP.md): metrics.pruneOlderThan() has existed since the
|
|
787
1182
|
// day metrics.js was written, but nothing ever actually CALLED it - the
|
|
@@ -854,4 +1249,4 @@ if (require.main === module) {
|
|
|
854
1249
|
});
|
|
855
1250
|
}
|
|
856
1251
|
|
|
857
|
-
module.exports = { app, isAuthConfigured, resolveEnvPathFromArgv, configure };
|
|
1252
|
+
module.exports = { app, isAuthConfigured, resolveEnvPathFromArgv, resolveTrustProxy, configure };
|