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/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/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 };