cachegate 1.3.1 → 1.4.1

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/cascade.js ADDED
@@ -0,0 +1,206 @@
1
+ // model-router/cascade.js
2
+ //
3
+ // Cascade routing (roadmap Phase 2 step 34): for a `router:` virtual model,
4
+ // dispatch to the tier's top-ranked candidate (router.js already produced
5
+ // that order), estimate how confident that response is, and if it's below a
6
+ // threshold, escalate to the NEXT-ranked candidate instead of accepting the
7
+ // cheap answer. This is orthogonal to failover.js's error-driven retry: a
8
+ // cheap candidate could fail outright (failover retries on error) or succeed
9
+ // unconfidently (cascade escalates on low confidence); both can apply to the
10
+ // same request.
11
+ //
12
+ // Gated off by default (CASCADE_ENABLED). Like every speculative feature in
13
+ // this codebase (step 22's local embeddings, step 25's guardrails), it ships
14
+ // inert and only activates when a deployment explicitly opts in.
15
+ //
16
+ // Confidence is per-provider, and there is no single obviously-right answer
17
+ // for every provider:
18
+ // - OpenAI: native logprobs. The request asks for logprobs only when
19
+ // cascade is active for that dispatch (see providers/openai.js); the
20
+ // response's per-token natural log-probabilities are reduced to one
21
+ // number via the geometric mean, exp(mean(logprobs)) - a defensible
22
+ // [0,1] figure, not an invented score.
23
+ // - Anthropic: no logprobs. Confidence comes from a grader model (one
24
+ // bounded extra request, not the 2-3x cost multiplier self-consistency
25
+ // would impose) - the caller supplies that as an injected async
26
+ // estimator, because a grader is a real provider call and therefore
27
+ // lives where provider calls live (server.js), not in this pure module.
28
+ // This module owns only the pure, testable halves of the grader: the
29
+ // prompt builder and the numeric-score parser.
30
+ //
31
+ // Pure control flow, mirroring coalescing.js's factoring: no provider
32
+ // clients, no network, no metrics store. Directly unit-testable with fake
33
+ // dispatch and confidence functions.
34
+
35
+ const failover = require('./failover');
36
+
37
+ // Gated off by default - cascade is genuinely more speculative than health
38
+ // scoring (grading one LLM's confidence in another LLM is inherently fuzzy),
39
+ // so it must prove itself before ever defaulting on.
40
+ const CASCADE_ENABLED = process.env.CASCADE_ENABLED === 'true';
41
+
42
+ // The confidence floor below which a successful response is escalated to the
43
+ // next-ranked candidate instead of accepted. Env-tunable; the default is a
44
+ // deliberately ordinary starting point, not a claim about any specific
45
+ // provider/model's real confidence distribution.
46
+ const CASCADE_CONFIDENCE_THRESHOLD = Number(process.env.CASCADE_CONFIDENCE_THRESHOLD) || 0.5;
47
+
48
+ function isEnabled() {
49
+ return CASCADE_ENABLED;
50
+ }
51
+
52
+ function threshold() {
53
+ return CASCADE_CONFIDENCE_THRESHOLD;
54
+ }
55
+
56
+ /**
57
+ * Confidence from an OpenAI response's native logprobs: the geometric mean of
58
+ * per-token probabilities, exp(mean(logprobs)) - a number in [0,1]. `result`
59
+ * is the shape providers/openai.js returns; its `raw` field is the full SDK
60
+ * response, whose `choices[0].logprobs.content[]` carries
61
+ * `{token, logprob, bytes, top_logprobs}` when the request asked for
62
+ * logprobs (providers/openai.js only asks when cascade is active for the
63
+ * dispatch).
64
+ *
65
+ * Returns null when there is no logprobs data (the request didn't ask, or
66
+ * the provider didn't return any) - a missing confidence signal must never
67
+ * be treated as low confidence (fail-open), the same discipline router.js's
68
+ * "insufficient data must never shed" holds to.
69
+ */
70
+ function openaiLogprobConfidence(result) {
71
+ const content = result && result.raw && result.raw.choices
72
+ && result.raw.choices[0] && result.raw.choices[0].logprobs
73
+ && result.raw.choices[0].logprobs.content;
74
+ if (!Array.isArray(content) || content.length === 0) return null;
75
+ const logprobs = content
76
+ .map((entry) => (entry && typeof entry.logprob === 'number' ? entry.logprob : null))
77
+ .filter((v) => v !== null && Number.isFinite(v));
78
+ if (logprobs.length === 0) return null;
79
+ const mean = logprobs.reduce((sum, v) => sum + v, 0) / logprobs.length;
80
+ return Math.exp(mean);
81
+ }
82
+
83
+ /**
84
+ * Parse a grader model's reply into a [0,1] confidence number, or null. The
85
+ * grader is prompted to reply with only a number; this tolerates surrounding
86
+ * prose/whitespace but never invents a score it can't read (null, not a
87
+ * guess, when there's no numeric token).
88
+ */
89
+ function parseGraderScore(text) {
90
+ if (typeof text !== 'string') return null;
91
+ const match = text.match(/[+-]?(?:\d+\.?\d*|\.\d+)/);
92
+ if (!match) return null;
93
+ const value = Number(match[0]);
94
+ if (!Number.isFinite(value)) return null;
95
+ return Math.min(1, Math.max(0, value));
96
+ }
97
+
98
+ /**
99
+ * Build the grader prompt messages: the original question plus the candidate
100
+ * response, asking for a single 0-1 number. `questionMessages` is the
101
+ * request's own messages (the question); `responseContent` is what the
102
+ * candidate answered.
103
+ */
104
+ function buildGraderMessages(questionMessages, responseContent) {
105
+ const question = questionMessages
106
+ .map((m) => `${m.role}: ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`)
107
+ .join('\n');
108
+ return [{
109
+ role: 'user',
110
+ content: [
111
+ 'Question:',
112
+ question,
113
+ '',
114
+ 'Response:',
115
+ String(responseContent == null ? '' : responseContent),
116
+ '',
117
+ 'Does this response fully and confidently answer the question? Reply with only a number between 0 and 1 (0 = not at all, 1 = fully and confidently).'
118
+ ].join('\n')
119
+ }];
120
+ }
121
+
122
+ /**
123
+ * Cascade orchestration for a virtual model's ranked candidates when cascade
124
+ * is active: walks the list once, failing over on ERROR and escalating on LOW
125
+ * CONFIDENCE. Reuses failover.isRetryableError so the "is this error worth
126
+ * retrying" decision has exactly one source of truth (failover.js); the walk
127
+ * itself is re-derived here only because cascade adds a second reason to move
128
+ * to the next candidate that failover.dispatchWithFailover doesn't know
129
+ * about. failover.dispatchWithFailover remains the path when cascade is
130
+ * disabled - the default is byte-identical to before this module existed.
131
+ *
132
+ * @param {Array<{provider: string, model: string}>} candidates ranked order
133
+ * (router.js's pickCandidate().rankedCandidates)
134
+ * @param {(candidate) => Promise<any>} dispatch one candidate's provider call
135
+ * @param {(result) => Promise<number|null>} estimateConfidence per-result
136
+ * confidence; null means "no signal" and is treated as CONFIDENT (fail-open)
137
+ * @param {object} [options]
138
+ * @param {number} [options.threshold] confidence floor for escalation
139
+ * @param {(candidate, err, isLastCandidate) => void} [options.onAttemptFailed]
140
+ * error metric hook (same contract as failover.dispatchWithFailover's)
141
+ * @param {(fromCandidate, toCandidate, cheapResult, failedOver) => void} [options.onEscalated]
142
+ * hook fired when a low-confidence success is escalated away (the cheap
143
+ * result's cost is the caller's to record here; it is deliberately NOT
144
+ * returned - rejecting it is the whole point of cascade). `failedOver` is
145
+ * true when an earlier candidate in this same walk already errored -
146
+ * informational only; the escalated-away candidate's OWN quality_score
147
+ * should always be recorded as non-perfect (e.g. 0.5) regardless of
148
+ * `failedOver`'s value, since a low-confidence rejection alone already
149
+ * disqualifies it from a perfect score - `failedOver` being false doesn't
150
+ * make a rejected answer any better.
151
+ * @returns {Promise<{result, candidate, attempts, cascaded, failedOver}>}
152
+ */
153
+ async function tryWithCascade(candidates, dispatch, estimateConfidence, options = {}) {
154
+ const {
155
+ threshold: confidenceThreshold = threshold(),
156
+ onAttemptFailed,
157
+ onEscalated
158
+ } = options;
159
+
160
+ let lastErr;
161
+ let failedOver = false;
162
+ let cascaded = false;
163
+
164
+ for (let i = 0; i < candidates.length; i++) {
165
+ const candidate = candidates[i];
166
+ const isLastCandidate = i === candidates.length - 1;
167
+ let result;
168
+ try {
169
+ result = await dispatch(candidate);
170
+ } catch (err) {
171
+ lastErr = err;
172
+ failedOver = true;
173
+ if (onAttemptFailed) onAttemptFailed(candidate, err, isLastCandidate);
174
+ if (!failover.isRetryableError(err) || isLastCandidate) throw err;
175
+ continue; // failover: try the next candidate
176
+ }
177
+
178
+ // Skip confidence estimation entirely on the last candidate: its result
179
+ // could never change the outcome (there's nowhere left to escalate TO),
180
+ // so paying for it would be pure waste - and for Anthropic candidates,
181
+ // that "cost" is a real grader-model API call plus the latency of
182
+ // waiting on it, not a free computation like OpenAI's logprobs.
183
+ const confidence = isLastCandidate ? null : await estimateConfidence(result);
184
+ if (typeof confidence === 'number' && confidence < confidenceThreshold && !isLastCandidate) {
185
+ // Low confidence, and a next candidate exists: escalate rather than
186
+ // accept the cheap answer.
187
+ if (onEscalated) onEscalated(candidate, candidates[i + 1], result, failedOver);
188
+ cascaded = true;
189
+ continue;
190
+ }
191
+ return { result, candidate, attempts: i + 1, cascaded, failedOver };
192
+ }
193
+
194
+ // Unreachable when candidates.length > 0 (the loop either returns or
195
+ // throws); kept honest for an empty list, mirroring failover.js.
196
+ throw lastErr;
197
+ }
198
+
199
+ module.exports = {
200
+ isEnabled,
201
+ threshold,
202
+ openaiLogprobConfidence,
203
+ parseGraderScore,
204
+ buildGraderMessages,
205
+ tryWithCascade
206
+ };
package/coalescing.js ADDED
@@ -0,0 +1,65 @@
1
+ // model-router/coalescing.js
2
+ //
3
+ // Request coalescing (single-flight, roadmap step 24): N identical
4
+ // concurrent cache-MISS requests share ONE upstream dispatch instead of N.
5
+ // Keyed by the exact cache key (cache.buildCacheKey), so only byte-
6
+ // identical requests coalesce - near-duplicates are the semantic cache's
7
+ // job, not this. A leader creates + stores the dispatch promise BEFORE
8
+ // awaiting it, so a second caller arriving mid-flight finds it; joiners
9
+ // await that same promise. The map entry self-removes when it settles.
10
+
11
+ const MAX_INFLIGHT = Number(process.env.COALESCE_MAX_INFLIGHT) || 500;
12
+ const WAIT_TIMEOUT_MS = Number(process.env.COALESCE_WAIT_TIMEOUT_MS) || 3000;
13
+
14
+ const inFlight = new Map(); // cacheKey -> { promise, traceId }
15
+
16
+ // Returns { result, coalesced, joinedTraceId? }. coalesced:true means this
17
+ // caller joined an existing in-flight dispatch (and should record a
18
+ // `coalesced` metric, zero NEW cost); coalesced:false means this caller was
19
+ // the leader, or dispatched independently after a join-timeout / capacity
20
+ // skip. `traceId` is this caller's own request trace id (stored when this
21
+ // caller becomes the leader); `joinedTraceId` is returned to a joiner only,
22
+ // pointing at the leader's trace id, so a joiner's metrics row can record
23
+ // both "who I am" (its own trace_id) and "whose dispatch I shared"
24
+ // (joined_trace_id) - the joiner never stopped being its own request.
25
+ async function joinOrRun(key, dispatch, traceId) {
26
+ const existing = inFlight.get(key);
27
+
28
+ if (existing) {
29
+ // Joiner: await the leader, but only for a bounded wait. If the leader
30
+ // hangs past WAIT_TIMEOUT_MS, fail OPEN (dispatch independently) rather
31
+ // than hang forever on someone else's stuck request. A leader that
32
+ // REJECTS before the timeout propagates that rejection to the joiner
33
+ // (Promise.race settles with the first settled promise), so a joiner
34
+ // never gets a false success.
35
+ const outcome = await Promise.race([
36
+ existing.promise.then((result) => ({ result, coalesced: true, joinedTraceId: existing.traceId })),
37
+ new Promise((resolve) => setTimeout(() => resolve(undefined), WAIT_TIMEOUT_MS))
38
+ ]);
39
+ if (outcome !== undefined) return outcome;
40
+ // timed out -> fall through to an independent dispatch below
41
+ } else if (inFlight.size < MAX_INFLIGHT) {
42
+ // Leader (and only while there's room): create + store the promise
43
+ // BEFORE awaiting it, so the next caller finds it. Self-removes on
44
+ // settle, success or failure.
45
+ const promise = dispatch();
46
+ inFlight.set(key, { promise, traceId });
47
+ try {
48
+ const result = await promise;
49
+ return { result, coalesced: false };
50
+ } finally {
51
+ // Remove only if it is still OUR entry (a timed-out joiner that
52
+ // dispatched independently never stored its own, so this guard is
53
+ // defensive against any future change that does).
54
+ if (inFlight.get(key) && inFlight.get(key).promise === promise) inFlight.delete(key);
55
+ }
56
+ }
57
+
58
+ // Timed-out joiner, or map at capacity: dispatch independently, no map
59
+ // entry (skip coalescing). The capacity path is a defensive cap against a
60
+ // pathological high-cardinality burst, not a real-world leak (entries
61
+ // already self-remove on settle).
62
+ return { result: await dispatch(), coalesced: false };
63
+ }
64
+
65
+ module.exports = { joinOrRun };
package/embeddings.js CHANGED
@@ -1,22 +1,67 @@
1
1
  // model-router/embeddings.js
2
2
  //
3
- // The only embedding backend right now is OpenAI's - which means
4
- // semantic caching needs an OPENAI_API_KEY configured even for a
5
- // deployment that only ever talks to Anthropic for chat. That's a real
6
- // constraint, not hidden: isEnabled() is what semanticCache.js checks
7
- // before doing anything, and it degrades to "disabled" (not an error)
8
- // when the key isn't set, same as the Redis cache does when REDIS_URL
9
- // isn't set.
3
+ // Two embedding backends, selected by SEMANTIC_CACHE_LOCAL_EMBEDDINGS
4
+ // (default OFF, roadmap step 22.4):
5
+ //
6
+ // - OpenAI (default): text-embedding-3-small, 1536-dim, needs
7
+ // OPENAI_API_KEY. The pre-existing behavior, unchanged - which means
8
+ // a deployment that only chats with Anthropic still needs an OpenAI key
9
+ // for semantic caching, exactly as before.
10
+ // - Local (opt-in): all-MiniLM-L6-v2 via @huggingface/transformers, 384-dim,
11
+ // pure JS/WASM - no native build, no API key, fully self-hosted. The
12
+ // model is downloaded and cached by transformers.js on FIRST use, so the
13
+ // first local embed() is slow; later calls are in-process.
14
+ //
15
+ // OPERATIONAL NOTE (not silent): flipping this flag on a deployment with
16
+ // an existing semantic cache orphans every previously-stored entry -
17
+ // semanticCache.js's cosineSimilarity() returns 0 for a length mismatch
18
+ // (its own guard) rather than throwing, so old 1536-dim entries simply
19
+ // never match again and age out via MAX_CANDIDATES_PER_MODEL's cap / TTL.
20
+ // Not a crash, but the cache warms up from zero again.
10
21
 
11
22
  const { OpenAI } = require('openai');
12
23
 
24
+ const LOCAL_EMBEDDING_DIMENSIONS = 384; // all-MiniLM-L6-v2
25
+ const OPENAI_EMBEDDING_DIMENSIONS = 1536; // text-embedding-3-small
26
+
13
27
  let client;
14
28
  function getClient() {
15
29
  if (!client) client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
16
30
  return client;
17
31
  }
18
32
 
33
+ // The local pipeline is lazy: @huggingface/transformers is heavy (WASM +
34
+ // model download on first use), so it must never load unless the flag is
35
+ // on. @huggingface/transformers (v3, same pipeline() API) rather than the
36
+ // frozen @xenova/transformers: the older package pins a vulnerable
37
+ // protobufjs transitively (CVSS 9.8) that ships to every installer.
38
+ let localPipeline;
39
+ async function getLocalPipeline() {
40
+ if (!localPipeline) {
41
+ const { pipeline } = await import('@huggingface/transformers');
42
+ localPipeline = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
43
+ }
44
+ return localPipeline;
45
+ }
46
+
47
+ function useLocal() {
48
+ return process.env.SEMANTIC_CACHE_LOCAL_EMBEDDINGS === 'true';
49
+ }
50
+
51
+ // Which backend is active, exposed so tests can pin "the flag actually
52
+ // switches backends" without downloading a model.
53
+ function activeBackend() {
54
+ return useLocal() ? 'local' : 'openai';
55
+ }
56
+
57
+ // Factored out of embed() so the timeout contract is directly testable
58
+ // (kept for the OpenAI network path; the local path is in-process).
59
+ function embedTimeoutMs() {
60
+ return Number(process.env.EMBEDDING_TIMEOUT_MS) || 5000;
61
+ }
62
+
19
63
  function isEnabled() {
64
+ if (useLocal()) return true;
20
65
  return Boolean(process.env.OPENAI_API_KEY);
21
66
  }
22
67
 
@@ -24,19 +69,36 @@ async function embed(text) {
24
69
  if (!isEnabled()) {
25
70
  throw new Error('OPENAI_API_KEY not configured - embeddings unavailable');
26
71
  }
72
+
73
+ if (useLocal()) {
74
+ // Local path: no network on the hot path after first use, but the
75
+ // FIRST call loads (and possibly downloads) the model - the same
76
+ // try/catch in semanticCache.js that degrades OpenAI timeouts to
77
+ // "skip semantic caching" also covers a local-load failure.
78
+ const pipeline = await getLocalPipeline();
79
+ const output = await pipeline(text, { pooling: 'mean', normalize: true });
80
+ return Array.from(output.data);
81
+ }
82
+
83
+ // OpenAI path (unchanged): a hard timeout per embedding call. These run
84
+ // on the hot request path (semanticCache.js's findMatch/store, one or
85
+ // two per miss), so a hung embedding provider must not be able to stall
86
+ // every chat request. AbortSignal.timeout aborts the underlying fetch,
87
+ // and the thrown error is caught by semanticCache.js's own try/catch,
88
+ // which degrades to "skip semantic caching" rather than failing.
27
89
  const model = process.env.EMBEDDING_MODEL || 'text-embedding-3-small';
28
- // A hard timeout per embedding call: these run on the hot request path
29
- // (semanticCache.js's findMatch/store, one or two per miss), so a hung
30
- // embedding provider must not be able to stall every chat request -
31
- // including ones that never touch embeddings at all if the semantic
32
- // cache is enabled. AbortSignal.timeout aborts the underlying fetch, and
33
- // the thrown error is caught by semanticCache.js's own try/catch, which
34
- // degrades to "skip semantic caching" rather than failing the request.
35
90
  const response = await getClient().embeddings.create(
36
91
  { model, input: text },
37
- { signal: AbortSignal.timeout(Number(process.env.EMBEDDING_TIMEOUT_MS) || 5000) }
92
+ { signal: AbortSignal.timeout(embedTimeoutMs()) }
38
93
  );
39
94
  return response.data[0].embedding;
40
95
  }
41
96
 
42
- module.exports = { isEnabled, embed };
97
+ module.exports = {
98
+ isEnabled,
99
+ embed,
100
+ embedTimeoutMs,
101
+ activeBackend,
102
+ LOCAL_EMBEDDING_DIMENSIONS,
103
+ OPENAI_EMBEDDING_DIMENSIONS
104
+ };
package/guardrails.js ADDED
@@ -0,0 +1,80 @@
1
+ // model-router/guardrails.js
2
+ //
3
+ // Prompt-injection detection + the policy-enforcement hook shape (roadmap
4
+ // step 25, Track B). Heuristic, not a classifier: a small set of
5
+ // high-signal patterns that catch the common injection shapes. Gated off
6
+ // by default behind GUARDRAILS_ENABLED. Standalone for now - not wired
7
+ // into server.js (the pre-dispatch wiring is the joint follow-up once
8
+ // pii.js lands, same reason Track A is also standalone).
9
+ //
10
+ // Because these are heuristics (and therefore false-positive-prone), the
11
+ // default action on a detection is `flag` (record + pass through), NOT
12
+ // `block`. A deployment that wants to reject on a hit sets
13
+ // GUARDRAILS_INJECTION_ACTION=block; the hook shape is what that decision
14
+ // plugs into, so block/flag/log are all expressible without a code change.
15
+
16
+ const INJECTION_PATTERNS = [
17
+ // Instruction-override: "ignore previous instructions", "disregard all
18
+ // prior rules", etc.
19
+ { id: 'ignore_previous_instructions', regex: /(?:ignore|disregard|forget|overwrite|override)\s+(?:all\s+)?(?:previous|prior|earlier|above|your)\s+(?:instructions?|rules?|prompts?)/i },
20
+ // System-prompt leak probes: "reveal your system prompt", "show me your
21
+ // instructions".
22
+ { id: 'system_prompt_leak', regex: /(?:reveal|show|print|display|repeat|output)\s+(?:me\s+)?(?:your|the)\s+(?:system\s+)?(?:prompt|instructions?|rules?|message|context)/i },
23
+ // Role-play jailbreak framing: "pretend you are", "act as", "roleplay as".
24
+ { id: 'roleplay_jailbreak', regex: /\b(?:pretend|act|roleplay|imagine|pose)\s+(?:you\s+are|as|to\s+be)\b/i },
25
+ // "developer mode" / "dev mode" jailbreak framing.
26
+ { id: 'developer_mode', regex: /\b(?:developer|dev)\s+mode\b/i },
27
+ // DAN / "do anything now".
28
+ { id: 'dan_jailbreak', regex: /\bDAN\b|\bdo\s+anything\s+now\b/i },
29
+ // "no restrictions", "bypass your filters", "remove your guardrails".
30
+ { id: 'no_restrictions', regex: /(?:no|without|bypass|ignore|remove)\s+(?:your\s+)?(?:restrictions?|limits?|limitations?|rules?|guardrails?|filters?)/i }
31
+ ];
32
+
33
+ // Scans one piece of text and returns the ids of every matched pattern
34
+ // (empty array = clean). Pure - no env, no I/O.
35
+ function detectInjection(text) {
36
+ const hits = [];
37
+ for (const pattern of INJECTION_PATTERNS) {
38
+ if (pattern.regex.test(String(text))) hits.push(pattern.id);
39
+ }
40
+ return hits;
41
+ }
42
+
43
+ function extractPromptText(messages) {
44
+ if (!Array.isArray(messages)) return '';
45
+ return messages
46
+ .map((m) => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content)))
47
+ .join('\n');
48
+ }
49
+
50
+ function isEnabled() {
51
+ return process.env.GUARDRAILS_ENABLED === 'true';
52
+ }
53
+
54
+ // The policy-enforcement hook: evaluate a request's messages and return a
55
+ // decision + reasons. `decision` is one of allow/block/flag/log. The
56
+ // shape is deliberately minimal so pii.js's redaction and the future
57
+ // server.js wiring can feed additional findings into the same decision
58
+ // without reshaping this module.
59
+ //
60
+ // Gated on isEnabled() INSIDE evaluate() itself, not just left to the
61
+ // caller to check - same "always callable, flag decides" contract as
62
+ // pii.js's redact(). Without this, a future server.js call site that
63
+ // forgot its own `if (guardrails.isEnabled())` guard would silently
64
+ // flag/block on every request regardless of the deployment's own
65
+ // GUARDRAILS_ENABLED setting - exactly the gap this module's own header
66
+ // comment claims doesn't exist ("gated off by default").
67
+ function evaluate(messages) {
68
+ if (!isEnabled()) {
69
+ return { decision: 'allow', reasons: [] };
70
+ }
71
+ const text = extractPromptText(messages);
72
+ const hits = detectInjection(text);
73
+ if (hits.length === 0) {
74
+ return { decision: 'allow', reasons: [] };
75
+ }
76
+ const action = process.env.GUARDRAILS_INJECTION_ACTION || 'flag'; // block | flag | log
77
+ return { decision: action, reasons: hits };
78
+ }
79
+
80
+ module.exports = { detectInjection, evaluate, isEnabled, INJECTION_PATTERNS };
package/metrics.js CHANGED
@@ -105,6 +105,11 @@ async function ensureSchema() {
105
105
  error_type TEXT
106
106
  );
107
107
  ALTER TABLE router_metrics ADD COLUMN IF NOT EXISTS scope TEXT;
108
+ ALTER TABLE router_metrics ADD COLUMN IF NOT EXISTS coalesced BOOLEAN;
109
+ ALTER TABLE router_metrics ADD COLUMN IF NOT EXISTS quality_score REAL;
110
+ ALTER TABLE router_metrics ADD COLUMN IF NOT EXISTS cascaded BOOLEAN;
111
+ ALTER TABLE router_metrics ADD COLUMN IF NOT EXISTS trace_id TEXT;
112
+ ALTER TABLE router_metrics ADD COLUMN IF NOT EXISTS joined_trace_id TEXT;
108
113
  CREATE INDEX IF NOT EXISTS router_metrics_ts_idx ON router_metrics (ts DESC);
109
114
  CREATE INDEX IF NOT EXISTS router_metrics_provider_ts_idx ON router_metrics (provider, ts DESC);
110
115
  CREATE INDEX IF NOT EXISTS router_metrics_scope_ts_idx ON router_metrics (scope, ts DESC);
@@ -127,6 +132,11 @@ function rowFromPg(dbRow) {
127
132
  model: dbRow.model || undefined,
128
133
  requested_model: dbRow.requested_model || undefined,
129
134
  cache_hit: dbRow.cache_hit === null ? undefined : dbRow.cache_hit,
135
+ coalesced: dbRow.coalesced === null ? undefined : dbRow.coalesced,
136
+ cascaded: dbRow.cascaded === null ? undefined : dbRow.cascaded,
137
+ quality_score: dbRow.quality_score === null ? undefined : dbRow.quality_score,
138
+ trace_id: dbRow.trace_id || undefined,
139
+ joined_trace_id: dbRow.joined_trace_id || undefined,
130
140
  cache_type: dbRow.cache_type || undefined,
131
141
  latency_ms: dbRow.latency_ms === null ? undefined : dbRow.latency_ms,
132
142
  cost_usd: dbRow.cost_usd === null ? undefined : dbRow.cost_usd,
@@ -140,19 +150,24 @@ async function recordToPostgres(scope, entry) {
140
150
  await ensureSchema();
141
151
  await getPool().query(
142
152
  `INSERT INTO router_metrics
143
- (scope, provider, model, requested_model, cache_hit, cache_type, latency_ms, cost_usd, error, error_type)
144
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
153
+ (scope, provider, model, requested_model, cache_hit, coalesced, cascaded, quality_score, cache_type, latency_ms, cost_usd, error, error_type, trace_id, joined_trace_id)
154
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`,
145
155
  [
146
156
  scope != null ? String(scope) : null,
147
157
  entry.provider ?? null,
148
158
  entry.model ?? null,
149
159
  entry.requested_model ?? null,
150
160
  entry.cache_hit ?? null,
161
+ entry.coalesced ?? null,
162
+ entry.cascaded ?? null,
163
+ entry.quality_score ?? null,
151
164
  entry.cache_type ?? null,
152
165
  entry.latency_ms ?? null,
153
166
  entry.cost_usd ?? null,
154
167
  entry.error ?? null,
155
- entry.error_type ?? null
168
+ entry.error_type ?? null,
169
+ entry.trace_id ?? null,
170
+ entry.joined_trace_id ?? null
156
171
  ]
157
172
  );
158
173
  } catch (err) {
@@ -427,6 +442,13 @@ async function providerStats(scope, windowSize = 50) {
427
442
  const avgLatencyMs = latencies.length
428
443
  ? latencies.reduce((sum, e) => sum + e.latency_ms, 0) / latencies.length
429
444
  : null;
445
+ // Mean quality across entries that actually HAVE a quality_score
446
+ // (cache hits, coalesced joiners, and pre-this-change rows don't set
447
+ // one) - same null-tolerant pattern avgLatencyMs uses for latency.
448
+ const qualityScores = recent.filter((e) => typeof e.quality_score === 'number');
449
+ const avgQualityScore = qualityScores.length
450
+ ? qualityScores.reduce((sum, e) => sum + e.quality_score, 0) / qualityScores.length
451
+ : null;
430
452
  // The MOST RECENT error only, not a tally of every type seen in the
431
453
  // window - an alert should reflect "what's wrong right now," not a
432
454
  // mix that might include something already fixed earlier in the
@@ -439,6 +461,7 @@ async function providerStats(scope, windowSize = 50) {
439
461
  sampleSize: recent.length,
440
462
  errorRate: recent.length ? errorEntries.length / recent.length : 0,
441
463
  avgLatencyMs,
464
+ avgQualityScore,
442
465
  lastErrorType: lastError ? lastError.error_type || classifyErrorType(lastError.error) : null,
443
466
  lastErrorAt: lastError ? lastError.timestamp : null
444
467
  };
@@ -472,6 +495,61 @@ async function providerStats(scope, windowSize = 50) {
472
495
  * underlying log, two different questions - not accidentally
473
496
  * duplicated logic.
474
497
  */
498
+
499
+ // Shared "$ saved" formula (mirrors cachegate-cloud's usage.mjs
500
+ // estimatedSavings / estimatedSavingsGlobal, 2026-09-05 Phase 2 step 20):
501
+ // for each model, the average cost of a cache-MISS (cache_hit === false,
502
+ // no error) times that model's cache-HIT count, summed across models. A
503
+ // model with hits but no recorded miss yet contributes 0 - never a
504
+ // cross-model average (usage.mjs's own documented honesty floor). Rows
505
+ // with no model are skipped. Pure (takes rows, returns { total, perDay })
506
+ // so it's directly unit-testable and shared by rangeSummary() and
507
+ // server.js's GET /stats without duplicating the formula.
508
+ function computeSavings(rows) {
509
+ const all = new Map(); // model -> accumulator
510
+ const perDay = new Map(); // YYYY-MM-DD -> Map(model -> accumulator)
511
+ const accFor = (map, key) => {
512
+ let acc = map.get(key);
513
+ if (!acc) {
514
+ acc = { missCostSum: 0, missCount: 0, hits: 0 };
515
+ map.set(key, acc);
516
+ }
517
+ return acc;
518
+ };
519
+ for (const row of rows) {
520
+ if (!row.model) continue;
521
+ const acc = accFor(all, row.model);
522
+ const date = row.timestamp.slice(0, 10);
523
+ let dayMap = perDay.get(date);
524
+ if (!dayMap) {
525
+ dayMap = new Map();
526
+ perDay.set(date, dayMap);
527
+ }
528
+ const dayAcc = accFor(dayMap, row.model);
529
+ if (row.cache_hit === true) {
530
+ acc.hits += 1;
531
+ dayAcc.hits += 1;
532
+ } else if (row.cache_hit === false && !row.error) {
533
+ acc.missCostSum += row.cost_usd || 0;
534
+ acc.missCount += 1;
535
+ dayAcc.missCostSum += row.cost_usd || 0;
536
+ dayAcc.missCount += 1;
537
+ }
538
+ }
539
+ const sum = (map) => {
540
+ let total = 0;
541
+ for (const acc of map.values()) {
542
+ if (acc.hits > 0 && acc.missCount > 0) {
543
+ total += (acc.missCostSum / acc.missCount) * acc.hits;
544
+ }
545
+ }
546
+ return total;
547
+ };
548
+ const out = new Map();
549
+ for (const [date, map] of perDay) out.set(date, sum(map));
550
+ return { total: sum(all), perDay: out };
551
+ }
552
+
475
553
  async function rangeSummary(scope, days = 14) {
476
554
  const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
477
555
 
@@ -508,7 +586,7 @@ async function rangeSummary(scope, days = 14) {
508
586
  for (const row of inRange) {
509
587
  const date = row.timestamp.slice(0, 10); // YYYY-MM-DD (UTC, from toISOString())
510
588
  if (!dailyByDate.has(date)) {
511
- dailyByDate.set(date, { date, requests: 0, cost_usd: 0, exact_hits: 0, semantic_hits: 0, misses: 0, errors: 0 });
589
+ dailyByDate.set(date, { date, requests: 0, cost_usd: 0, saved_usd: 0, exact_hits: 0, semantic_hits: 0, misses: 0, errors: 0 });
512
590
  }
513
591
  const bucket = dailyByDate.get(date);
514
592
  bucket.requests += 1;
@@ -541,6 +619,8 @@ async function rangeSummary(scope, days = 14) {
541
619
  }
542
620
  }
543
621
 
622
+ const savings = computeSavings(inRange);
623
+
544
624
  const providerSummary = {};
545
625
  for (const [name, p] of Object.entries(byProvider)) {
546
626
  providerSummary[name] = {
@@ -555,6 +635,7 @@ async function rangeSummary(scope, days = 14) {
555
635
  days,
556
636
  sample_size: inRange.length,
557
637
  total_cost_usd: totalCostUsd,
638
+ saved_usd: savings.total,
558
639
  cache_hit_rate: {
559
640
  exact: inRange.length ? exactHits / inRange.length : 0,
560
641
  semantic: inRange.length ? semanticHits / inRange.length : 0,
@@ -562,7 +643,9 @@ async function rangeSummary(scope, days = 14) {
562
643
  },
563
644
  error_rate: inRange.length ? errors / inRange.length : 0,
564
645
  by_provider: providerSummary,
565
- daily: [...dailyByDate.values()].sort((a, b) => a.date.localeCompare(b.date))
646
+ daily: [...dailyByDate.values()]
647
+ .sort((a, b) => a.date.localeCompare(b.date))
648
+ .map((d) => ({ ...d, saved_usd: savings.perDay.get(d.date) || 0 }))
566
649
  };
567
650
  }
568
651
 
@@ -647,6 +730,7 @@ module.exports = {
647
730
  readRecent,
648
731
  providerStats,
649
732
  rangeSummary,
733
+ computeSavings,
650
734
  pruneOlderThan,
651
735
  pruneScopedOlderThan,
652
736
  currentLogPath,