cachegate 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +83 -4
- package/LICENSE +21 -21
- package/README.md +154 -15
- package/cache.js +133 -9
- package/cascade.js +206 -0
- package/coalescing.js +65 -0
- package/embeddings.js +78 -16
- package/failover.js +76 -76
- package/guardrails.js +80 -0
- package/metrics.js +93 -7
- package/package.json +12 -2
- package/pii.js +128 -0
- package/providers/anthropic.js +122 -122
- package/providers/deepseek.js +194 -0
- package/providers/index.js +62 -0
- package/providers/openai.js +122 -114
- package/providers/openrouter.js +233 -0
- package/public/dashboard.html +1120 -1119
- package/router.js +33 -4
- package/semanticCache.js +115 -3
- package/server.js +494 -99
- package/streaming.js +77 -77
- package/tracing.js +97 -0
package/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/failover.js
CHANGED
|
@@ -1,76 +1,76 @@
|
|
|
1
|
-
// model-router/failover.js
|
|
2
|
-
//
|
|
3
|
-
// Pure control flow for trying a virtual model's ranked candidates in
|
|
4
|
-
// order until one succeeds - isolated from the actual provider-calling
|
|
5
|
-
// code (server.js's dispatchToProvider) so it's directly unit-testable
|
|
6
|
-
// with a fake dispatch function, no live API keys or network calls
|
|
7
|
-
// needed. Same reasoning as providers/*.js's own
|
|
8
|
-
// applyStreamEvent/applyStreamChunk factoring: the trickiest logic
|
|
9
|
-
// shouldn't require a real provider to test.
|
|
10
|
-
//
|
|
11
|
-
// Addresses ROADMAP.md's gap #2 ("no provider failover on a
|
|
12
|
-
// 5xx/rate-limit"): router.js's pickCandidate() already ranks every
|
|
13
|
-
// candidate in a tier by the configured strategy, but server.js used
|
|
14
|
-
// to dispatch to the top-ranked one only - if it failed, the whole
|
|
15
|
-
// request failed, even when a second healthy candidate existed in the
|
|
16
|
-
// same tier. This module is what actually walks that ranked list.
|
|
17
|
-
|
|
18
|
-
// Whether a failed dispatch attempt is worth retrying against the NEXT
|
|
19
|
-
// candidate, vs failing the request outright. The distinction: is the
|
|
20
|
-
// REQUEST itself broken (retrying elsewhere would fail identically),
|
|
21
|
-
// or did THIS provider fail in a way another provider might not (rate
|
|
22
|
-
// limit, an outage, a bad or expired key)? Bad request (400) and
|
|
23
|
-
// unknown model (404) are the request's own fault, not retried - the
|
|
24
|
-
// Anthropic and OpenAI SDKs both set `.status` on a thrown APIError.
|
|
25
|
-
// A network-level failure with no HTTP response at all (no `.status`)
|
|
26
|
-
// is treated as the provider's fault too, since it's not the
|
|
27
|
-
// request's content that's the problem. An auth failure (401) or
|
|
28
|
-
// missing-key misconfiguration is ALSO treated as retryable on
|
|
29
|
-
// purpose: a different candidate in the tier may use a different
|
|
30
|
-
// provider whose key is fine, so the request can still succeed - the
|
|
31
|
-
// broken key itself still surfaces on the dashboard's Provider alerts
|
|
32
|
-
// table via the metrics.record() call made before moving on (see
|
|
33
|
-
// server.js), so failover keeps requests succeeding without hiding
|
|
34
|
-
// the underlying problem from whoever needs to go fix that key.
|
|
35
|
-
function isRetryableError(err) {
|
|
36
|
-
const status = err && (err.status || err.statusCode);
|
|
37
|
-
return status !== 400 && status !== 404;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Calls `dispatch(candidate)` for each candidate in order until one
|
|
42
|
-
* resolves. On a rejection, calls `onAttemptFailed(candidate, err,
|
|
43
|
-
* isLastCandidate)` (for logging/metrics only - it has no bearing on
|
|
44
|
-
* control flow) and, unless the error is non-retryable or this was
|
|
45
|
-
* the last candidate, moves on to the next one. Rethrows the error
|
|
46
|
-
* from the LAST attempt if every candidate fails - the caller decides
|
|
47
|
-
* what HTTP status/response that becomes.
|
|
48
|
-
*
|
|
49
|
-
* Resolves to `{ result, candidate, attempts }` on success -
|
|
50
|
-
* `attempts` is 1 when the first candidate just worked, >1 when
|
|
51
|
-
* failover actually happened (worth logging distinctly - see
|
|
52
|
-
* server.js's caller).
|
|
53
|
-
*
|
|
54
|
-
* @param {Array<{provider: string, model: string}>} candidates ranked
|
|
55
|
-
* order, e.g. router.js's pickCandidate().rankedCandidates
|
|
56
|
-
* @param {(candidate) => Promise<any>} dispatch
|
|
57
|
-
* @param {(candidate, err, isLastCandidate) => void} [onAttemptFailed]
|
|
58
|
-
*/
|
|
59
|
-
async function dispatchWithFailover(candidates, dispatch, onAttemptFailed) {
|
|
60
|
-
let lastErr;
|
|
61
|
-
for (let i = 0; i < candidates.length; i++) {
|
|
62
|
-
const candidate = candidates[i];
|
|
63
|
-
const isLastCandidate = i === candidates.length - 1;
|
|
64
|
-
try {
|
|
65
|
-
const result = await dispatch(candidate);
|
|
66
|
-
return { result, candidate, attempts: i + 1 };
|
|
67
|
-
} catch (err) {
|
|
68
|
-
lastErr = err;
|
|
69
|
-
if (onAttemptFailed) onAttemptFailed(candidate, err, isLastCandidate);
|
|
70
|
-
if (!isRetryableError(err) || isLastCandidate) throw err;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
throw lastErr; // unreachable when candidates.length > 0; kept honest for an empty list
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
module.exports = { isRetryableError, dispatchWithFailover };
|
|
1
|
+
// model-router/failover.js
|
|
2
|
+
//
|
|
3
|
+
// Pure control flow for trying a virtual model's ranked candidates in
|
|
4
|
+
// order until one succeeds - isolated from the actual provider-calling
|
|
5
|
+
// code (server.js's dispatchToProvider) so it's directly unit-testable
|
|
6
|
+
// with a fake dispatch function, no live API keys or network calls
|
|
7
|
+
// needed. Same reasoning as providers/*.js's own
|
|
8
|
+
// applyStreamEvent/applyStreamChunk factoring: the trickiest logic
|
|
9
|
+
// shouldn't require a real provider to test.
|
|
10
|
+
//
|
|
11
|
+
// Addresses ROADMAP.md's gap #2 ("no provider failover on a
|
|
12
|
+
// 5xx/rate-limit"): router.js's pickCandidate() already ranks every
|
|
13
|
+
// candidate in a tier by the configured strategy, but server.js used
|
|
14
|
+
// to dispatch to the top-ranked one only - if it failed, the whole
|
|
15
|
+
// request failed, even when a second healthy candidate existed in the
|
|
16
|
+
// same tier. This module is what actually walks that ranked list.
|
|
17
|
+
|
|
18
|
+
// Whether a failed dispatch attempt is worth retrying against the NEXT
|
|
19
|
+
// candidate, vs failing the request outright. The distinction: is the
|
|
20
|
+
// REQUEST itself broken (retrying elsewhere would fail identically),
|
|
21
|
+
// or did THIS provider fail in a way another provider might not (rate
|
|
22
|
+
// limit, an outage, a bad or expired key)? Bad request (400) and
|
|
23
|
+
// unknown model (404) are the request's own fault, not retried - the
|
|
24
|
+
// Anthropic and OpenAI SDKs both set `.status` on a thrown APIError.
|
|
25
|
+
// A network-level failure with no HTTP response at all (no `.status`)
|
|
26
|
+
// is treated as the provider's fault too, since it's not the
|
|
27
|
+
// request's content that's the problem. An auth failure (401) or
|
|
28
|
+
// missing-key misconfiguration is ALSO treated as retryable on
|
|
29
|
+
// purpose: a different candidate in the tier may use a different
|
|
30
|
+
// provider whose key is fine, so the request can still succeed - the
|
|
31
|
+
// broken key itself still surfaces on the dashboard's Provider alerts
|
|
32
|
+
// table via the metrics.record() call made before moving on (see
|
|
33
|
+
// server.js), so failover keeps requests succeeding without hiding
|
|
34
|
+
// the underlying problem from whoever needs to go fix that key.
|
|
35
|
+
function isRetryableError(err) {
|
|
36
|
+
const status = err && (err.status || err.statusCode);
|
|
37
|
+
return status !== 400 && status !== 404;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Calls `dispatch(candidate)` for each candidate in order until one
|
|
42
|
+
* resolves. On a rejection, calls `onAttemptFailed(candidate, err,
|
|
43
|
+
* isLastCandidate)` (for logging/metrics only - it has no bearing on
|
|
44
|
+
* control flow) and, unless the error is non-retryable or this was
|
|
45
|
+
* the last candidate, moves on to the next one. Rethrows the error
|
|
46
|
+
* from the LAST attempt if every candidate fails - the caller decides
|
|
47
|
+
* what HTTP status/response that becomes.
|
|
48
|
+
*
|
|
49
|
+
* Resolves to `{ result, candidate, attempts }` on success -
|
|
50
|
+
* `attempts` is 1 when the first candidate just worked, >1 when
|
|
51
|
+
* failover actually happened (worth logging distinctly - see
|
|
52
|
+
* server.js's caller).
|
|
53
|
+
*
|
|
54
|
+
* @param {Array<{provider: string, model: string}>} candidates ranked
|
|
55
|
+
* order, e.g. router.js's pickCandidate().rankedCandidates
|
|
56
|
+
* @param {(candidate) => Promise<any>} dispatch
|
|
57
|
+
* @param {(candidate, err, isLastCandidate) => void} [onAttemptFailed]
|
|
58
|
+
*/
|
|
59
|
+
async function dispatchWithFailover(candidates, dispatch, onAttemptFailed) {
|
|
60
|
+
let lastErr;
|
|
61
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
62
|
+
const candidate = candidates[i];
|
|
63
|
+
const isLastCandidate = i === candidates.length - 1;
|
|
64
|
+
try {
|
|
65
|
+
const result = await dispatch(candidate);
|
|
66
|
+
return { result, candidate, attempts: i + 1 };
|
|
67
|
+
} catch (err) {
|
|
68
|
+
lastErr = err;
|
|
69
|
+
if (onAttemptFailed) onAttemptFailed(candidate, err, isLastCandidate);
|
|
70
|
+
if (!isRetryableError(err) || isLastCandidate) throw err;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
throw lastErr; // unreachable when candidates.length > 0; kept honest for an empty list
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { isRetryableError, dispatchWithFailover };
|
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 };
|