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/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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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(
|
|
92
|
+
{ signal: AbortSignal.timeout(embedTimeoutMs()) }
|
|
38
93
|
);
|
|
39
94
|
return response.data[0].embedding;
|
|
40
95
|
}
|
|
41
96
|
|
|
42
|
-
module.exports = {
|
|
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()]
|
|
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,
|