cachegate 1.3.1 → 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/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/guardrails.js +80 -0
- package/metrics.js +89 -5
- package/package.json +12 -2
- package/pii.js +128 -0
- package/providers/deepseek.js +194 -0
- package/providers/index.js +62 -0
- package/providers/openai.js +10 -2
- package/providers/openrouter.js +233 -0
- package/public/dashboard.html +1 -0
- package/router.js +33 -4
- package/semanticCache.js +115 -3
- package/server.js +494 -118
- package/tracing.js +97 -0
package/server.js
CHANGED
|
@@ -30,30 +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();
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
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));
|
|
57
143
|
// No X-Powered-By: Express - free, standard hardening (avoids handing a
|
|
58
144
|
// public-facing service's framework fingerprint to every caller for no
|
|
59
145
|
// benefit).
|
|
@@ -151,8 +237,10 @@ const seams = {
|
|
|
151
237
|
// (See the callers below: they never cache a client built from a
|
|
152
238
|
// non-default resolver's key, so a decrypted per-tenant secret never
|
|
153
239
|
// outlives the one request it was resolved for.)
|
|
154
|
-
resolveProviderKey: (scope, provider) =>
|
|
155
|
-
|
|
240
|
+
resolveProviderKey: (scope, provider) => {
|
|
241
|
+
const key = providers.envKey(provider);
|
|
242
|
+
return (key ? process.env[key] : null) || null;
|
|
243
|
+
},
|
|
156
244
|
|
|
157
245
|
// Passed straight through as express-rate-limit's own `keyGenerator`.
|
|
158
246
|
// Default: undefined, so express-rate-limit's own per-IP default
|
|
@@ -280,25 +368,31 @@ app.use('/v1', rateLimiter, requireInternalKey, express.json({ limit: process.en
|
|
|
280
368
|
// tenant), every call below builds a fresh client instead of caching
|
|
281
369
|
// one - a decrypted secret must never outlive the one request it was
|
|
282
370
|
// resolved for.
|
|
283
|
-
|
|
284
|
-
|
|
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();
|
|
285
376
|
|
|
286
|
-
async function
|
|
287
|
-
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);
|
|
288
381
|
if (scope == null) {
|
|
289
|
-
if (!
|
|
290
|
-
return
|
|
382
|
+
if (!defaultClients.has(provider)) defaultClients.set(provider, mod.buildClient(key));
|
|
383
|
+
return defaultClients.get(provider);
|
|
291
384
|
}
|
|
292
|
-
return
|
|
385
|
+
return mod.buildClient(key);
|
|
293
386
|
}
|
|
294
387
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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);
|
|
302
396
|
}
|
|
303
397
|
|
|
304
398
|
function isModelAnthropic(model) {
|
|
@@ -352,6 +446,7 @@ app.get('/stats', requireInternalKey, readEndpointLimiter, async (req, res) => {
|
|
|
352
446
|
const totalCostUsd = recent.reduce((sum, r) => sum + (r.cost_usd || 0), 0);
|
|
353
447
|
const exactHits = recent.filter((r) => r.cache_hit && r.cache_type !== 'semantic').length;
|
|
354
448
|
const semanticHits = recent.filter((r) => r.cache_hit && r.cache_type === 'semantic').length;
|
|
449
|
+
const savedUsd = metrics.computeSavings(recent).total;
|
|
355
450
|
res.json({
|
|
356
451
|
sample_size: recent.length,
|
|
357
452
|
cache_hit_rate: {
|
|
@@ -360,6 +455,7 @@ app.get('/stats', requireInternalKey, readEndpointLimiter, async (req, res) => {
|
|
|
360
455
|
combined: recent.length ? (exactHits + semanticHits) / recent.length : 0
|
|
361
456
|
},
|
|
362
457
|
total_cost_usd: totalCostUsd,
|
|
458
|
+
saved_usd: savedUsd,
|
|
363
459
|
by_provider: byProvider,
|
|
364
460
|
// Internal routing configuration - which providers have a key
|
|
365
461
|
// configured, which virtual-model tiers exist, and which strategy picks
|
|
@@ -476,17 +572,75 @@ function streamCachedReplay(res, entry, cacheType) {
|
|
|
476
572
|
// against more than one provider in the same request. Throws an error
|
|
477
573
|
// with `.status` set so failover.isRetryableError() can decide whether
|
|
478
574
|
// it's worth trying the next candidate.
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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 });
|
|
485
585
|
}
|
|
486
|
-
|
|
487
|
-
|
|
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;
|
|
488
643
|
}
|
|
489
|
-
return openaiProvider.chat(await getOpenAiClient(scope), payload);
|
|
490
644
|
}
|
|
491
645
|
|
|
492
646
|
// The real streaming dispatch path: an actual cache miss, forwarded
|
|
@@ -499,18 +653,15 @@ async function dispatchToProvider(scope, provider, payload) {
|
|
|
499
653
|
// about which model answered - a materially harder problem than the
|
|
500
654
|
// non-streaming case, left as a documented gap rather than shipped
|
|
501
655
|
// half-working (see ROADMAP.md).
|
|
502
|
-
async function handleStreamingDispatch(req, res, payload, requestedModel, routingDecision) {
|
|
656
|
+
async function handleStreamingDispatch(req, res, payload, requestedModel, routingDecision, traceId) {
|
|
503
657
|
const scope = req.scope;
|
|
504
|
-
|
|
505
|
-
if (
|
|
506
|
-
providerName = 'anthropic';
|
|
507
|
-
if (!(await seams.resolveProviderKey(scope, 'anthropic'))) return res.status(500).json({ error: 'ANTHROPIC_API_KEY not configured' });
|
|
508
|
-
} else if (isModelOpenAi(payload.model)) {
|
|
509
|
-
providerName = 'openai';
|
|
510
|
-
if (!(await seams.resolveProviderKey(scope, 'openai'))) return res.status(500).json({ error: 'OPENAI_API_KEY not configured' });
|
|
511
|
-
} else {
|
|
658
|
+
const providerName = providerForModel(payload.model);
|
|
659
|
+
if (!providerName) {
|
|
512
660
|
return res.status(400).json({ error: `Unsupported model: ${payload.model}` });
|
|
513
661
|
}
|
|
662
|
+
if (!(await seams.resolveProviderKey(scope, providerName))) {
|
|
663
|
+
return res.status(500).json({ error: `${providers.envKey(providerName)} not configured` });
|
|
664
|
+
}
|
|
514
665
|
|
|
515
666
|
streaming.startSse(res);
|
|
516
667
|
const id = streaming.genId();
|
|
@@ -523,8 +674,8 @@ async function handleStreamingDispatch(req, res, payload, requestedModel, routin
|
|
|
523
674
|
|
|
524
675
|
let result;
|
|
525
676
|
try {
|
|
526
|
-
const client =
|
|
527
|
-
const chatStreamFn = providerName
|
|
677
|
+
const client = await getProviderClient(scope, providerName);
|
|
678
|
+
const chatStreamFn = providers.get(providerName).chatStream;
|
|
528
679
|
result = await chatStreamFn(client, payload, {
|
|
529
680
|
signal: controller.signal,
|
|
530
681
|
onDelta: (text) => res.write(streaming.deltaChunk({ id, model: payload.model, content: text }))
|
|
@@ -543,6 +694,8 @@ async function handleStreamingDispatch(req, res, payload, requestedModel, routin
|
|
|
543
694
|
model: payload.model,
|
|
544
695
|
requested_model: requestedModel,
|
|
545
696
|
cache_hit: false,
|
|
697
|
+
quality_score: 0.0,
|
|
698
|
+
trace_id: traceId,
|
|
546
699
|
error: err.message,
|
|
547
700
|
error_type: metrics.classifyErrorType(err.message)
|
|
548
701
|
});
|
|
@@ -568,12 +721,30 @@ async function handleStreamingDispatch(req, res, payload, requestedModel, routin
|
|
|
568
721
|
model: result.model,
|
|
569
722
|
requested_model: requestedModel,
|
|
570
723
|
cache_hit: false,
|
|
724
|
+
quality_score: 1.0,
|
|
725
|
+
trace_id: traceId,
|
|
571
726
|
latency_ms: result.latency_ms,
|
|
572
727
|
cost_usd: result.cost_usd
|
|
573
728
|
});
|
|
574
729
|
}
|
|
575
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.
|
|
576
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) {
|
|
577
748
|
const payload = req.body;
|
|
578
749
|
const scope = req.scope; // set by requireInternalKey/seams.authenticate - null unless configured
|
|
579
750
|
|
|
@@ -620,13 +791,51 @@ app.post('/v1/chat/completions', async (req, res) => {
|
|
|
620
791
|
payload.model = routingDecision.model;
|
|
621
792
|
}
|
|
622
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
|
+
|
|
623
813
|
// 1. Try the exact-match cache first - free, zero-risk, checked
|
|
624
814
|
// before anything else (keyed on the resolved concrete model, so a
|
|
625
815
|
// routed request and a direct request for the same concrete model
|
|
626
816
|
// share the same cache entries). A hit is served the same way
|
|
627
817
|
// whether or not the caller asked for stream:true - see
|
|
628
818
|
// streamCachedReplay() for the streaming case.
|
|
629
|
-
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
|
+
|
|
630
839
|
if (cached) {
|
|
631
840
|
metrics.record(scope, {
|
|
632
841
|
provider: cached.provider,
|
|
@@ -634,6 +843,7 @@ app.post('/v1/chat/completions', async (req, res) => {
|
|
|
634
843
|
requested_model: requestedModel,
|
|
635
844
|
cache_hit: true,
|
|
636
845
|
cache_type: 'exact',
|
|
846
|
+
trace_id: traceId,
|
|
637
847
|
latency_ms: 0,
|
|
638
848
|
cost_usd: 0
|
|
639
849
|
});
|
|
@@ -661,7 +871,7 @@ app.post('/v1/chat/completions', async (req, res) => {
|
|
|
661
871
|
// prompt, not an identical one). This costs one embedding call
|
|
662
872
|
// whether or not it finds anything; see semanticCache.js for why
|
|
663
873
|
// that's a deliberate tradeoff, not overhead to optimize away.
|
|
664
|
-
const semanticMatch = await semanticCache.findMatch(scope, payload);
|
|
874
|
+
const semanticMatch = await tracing.withSpan('cache.semantic', { trace_id: traceId }, () => semanticCache.findMatch(scope, payload));
|
|
665
875
|
if (semanticMatch) {
|
|
666
876
|
const hit = semanticMatch.entry;
|
|
667
877
|
metrics.record(scope, {
|
|
@@ -671,6 +881,7 @@ app.post('/v1/chat/completions', async (req, res) => {
|
|
|
671
881
|
cache_hit: true,
|
|
672
882
|
cache_type: 'semantic',
|
|
673
883
|
semantic_similarity: semanticMatch.similarity,
|
|
884
|
+
trace_id: traceId,
|
|
674
885
|
latency_ms: 0,
|
|
675
886
|
cost_usd: 0
|
|
676
887
|
});
|
|
@@ -701,76 +912,220 @@ app.post('/v1/chat/completions', async (req, res) => {
|
|
|
701
912
|
// so the two need different error-reporting strategies (see
|
|
702
913
|
// handleStreamingDispatch's error frame vs. this path's 502 JSON).
|
|
703
914
|
if (wantsStream) {
|
|
704
|
-
return handleStreamingDispatch(req, res, payload, requestedModel, routingDecision);
|
|
915
|
+
return handleStreamingDispatch(req, res, payload, requestedModel, routingDecision, traceId);
|
|
705
916
|
}
|
|
706
917
|
|
|
707
918
|
try {
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
(
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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,
|
|
727
1063
|
requested_model: requestedModel,
|
|
728
1064
|
cache_hit: false,
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
|
732
1075
|
);
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
} else if (isModelAnthropic(payload.model)) {
|
|
740
|
-
if (!(await seams.resolveProviderKey(scope, 'anthropic'))) {
|
|
741
|
-
return res.status(500).json({ error: 'ANTHROPIC_API_KEY not configured' });
|
|
742
|
-
}
|
|
743
|
-
result = await anthropicProvider.chat(await getAnthropicClient(scope), payload);
|
|
744
|
-
} else if (isModelOpenAi(payload.model)) {
|
|
745
|
-
if (!(await seams.resolveProviderKey(scope, 'openai'))) {
|
|
746
|
-
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
|
+
}
|
|
747
1082
|
}
|
|
748
|
-
|
|
749
|
-
} else {
|
|
750
|
-
return res.status(400).json({ error: `Unsupported model: ${payload.model}` });
|
|
1083
|
+
coalesceSpan.end();
|
|
751
1084
|
}
|
|
752
1085
|
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
// their prerequisites (Redis / OPENAI_API_KEY) aren't configured.
|
|
756
|
-
await cache.set(scope, payload, result);
|
|
757
|
-
await semanticCache.store(scope, payload, result);
|
|
1086
|
+
const { result: dispatchOutcome, coalesced: wasCoalesced, joinedTraceId } = joined;
|
|
1087
|
+
const { result, failedOver, cascaded } = dispatchOutcome;
|
|
758
1088
|
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
|
+
}
|
|
767
1120
|
|
|
768
1121
|
res.json({
|
|
769
1122
|
cached: false,
|
|
1123
|
+
coalesced: wasCoalesced ? true : undefined,
|
|
770
1124
|
provider: result.provider,
|
|
771
1125
|
model: result.model,
|
|
772
1126
|
routed_from: routingDecision ? requestedModel : undefined,
|
|
773
1127
|
failover: failedOver ? true : undefined,
|
|
1128
|
+
cascaded: cascaded ? true : undefined,
|
|
774
1129
|
latency_ms: result.latency_ms,
|
|
775
1130
|
usage: result.usage,
|
|
776
1131
|
cost_usd: result.cost_usd,
|
|
@@ -784,23 +1139,44 @@ app.post('/v1/chat/completions', async (req, res) => {
|
|
|
784
1139
|
});
|
|
785
1140
|
} catch (err) {
|
|
786
1141
|
console.error('❌ Model router error:', err.message);
|
|
787
|
-
if (!routingDecision) {
|
|
1142
|
+
if (!routingDecision && !err.skipMetric) {
|
|
788
1143
|
// Virtual-model attempts already record one metrics entry PER
|
|
789
1144
|
// candidate as each fails (see the onAttemptFailed callback
|
|
790
1145
|
// above), including whichever one was last - recording again
|
|
791
|
-
// 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.
|
|
792
1152
|
metrics.record(scope, {
|
|
793
|
-
|
|
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',
|
|
794
1156
|
model: payload.model,
|
|
795
1157
|
requested_model: requestedModel,
|
|
796
1158
|
cache_hit: false,
|
|
1159
|
+
quality_score: 0.0,
|
|
1160
|
+
trace_id: traceId,
|
|
797
1161
|
error: err.message,
|
|
798
1162
|
error_type: metrics.classifyErrorType(err.message)
|
|
799
1163
|
});
|
|
800
1164
|
}
|
|
801
|
-
|
|
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 });
|
|
802
1178
|
}
|
|
803
|
-
}
|
|
1179
|
+
}
|
|
804
1180
|
|
|
805
1181
|
// Step 14 (ROADMAP.md): metrics.pruneOlderThan() has existed since the
|
|
806
1182
|
// day metrics.js was written, but nothing ever actually CALLED it - the
|
|
@@ -873,4 +1249,4 @@ if (require.main === module) {
|
|
|
873
1249
|
});
|
|
874
1250
|
}
|
|
875
1251
|
|
|
876
|
-
module.exports = { app, isAuthConfigured, resolveEnvPathFromArgv, configure };
|
|
1252
|
+
module.exports = { app, isAuthConfigured, resolveEnvPathFromArgv, resolveTrustProxy, configure };
|