cachegate 1.3.1 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cachegate",
3
- "version": "1.3.1",
3
+ "version": "1.4.1",
4
4
  "description": "Self-hostable, OpenAI-compatible LLM proxy: routes to the cheapest healthy provider, caches responses exactly and semantically, tracks cost and latency per call.",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
@@ -10,13 +10,18 @@
10
10
  "files": [
11
11
  "server.js",
12
12
  "cache.js",
13
+ "cascade.js",
14
+ "coalescing.js",
13
15
  "embeddings.js",
14
16
  "failover.js",
17
+ "guardrails.js",
15
18
  "metrics.js",
19
+ "pii.js",
16
20
  "redisClient.js",
17
21
  "router.js",
18
22
  "semanticCache.js",
19
23
  "streaming.js",
24
+ "tracing.js",
20
25
  "providers/",
21
26
  "public/",
22
27
  ".env.example"
@@ -26,10 +31,15 @@
26
31
  },
27
32
  "scripts": {
28
33
  "start": "node server.js",
29
- "test": "node --test"
34
+ "test": "node --test",
35
+ "eval:semantic-cache": "node eval/semantic-cache-eval.js"
30
36
  },
31
37
  "dependencies": {
32
38
  "@anthropic-ai/sdk": "^0.115.0",
39
+ "@huggingface/transformers": "^3.0.0",
40
+ "@opentelemetry/api": "^1.9.0",
41
+ "@opentelemetry/exporter-trace-otlp-http": "^0.222.0",
42
+ "@opentelemetry/sdk-node": "^0.222.0",
33
43
  "dotenv": "^16.3.1",
34
44
  "express": "^4.18.2",
35
45
  "express-rate-limit": "^8.6.2",
package/pii.js ADDED
@@ -0,0 +1,128 @@
1
+ // model-router/pii.js
2
+ //
3
+ // Pattern-based PII detection + redaction (roadmap step 25, PII track).
4
+ // Gated behind GUARDRAILS_PII_REDACTION (default OFF, same off-by-default
5
+ // convention as step 22's local embeddings) - ships fully inert until a
6
+ // deployment opts in. Standalone for now: NOT wired into server.js yet
7
+ // (see the step 25 kickoff directive) - this module only exposes the
8
+ // detection/redaction primitive; the pre-dispatch request-path wiring
9
+ // (and the coordination with the injection/policy track it needs to
10
+ // share a hook shape with) is a separate, joint follow-up change.
11
+ //
12
+ // Deliberately pattern/regex based, not a model call: PII redaction has
13
+ // to be synchronous, fast (it would run on every request, not just
14
+ // cache misses), and free of its own network dependency - a step whose
15
+ // whole job is stripping sensitive content out of a request shouldn't
16
+ // itself be a network hop that could leak that content to a third party.
17
+ //
18
+ // Redaction never surfaces the actual matched value anywhere, even in
19
+ // its own return value - only { type, count } counters. A false
20
+ // positive costs an unnecessary redaction (annoying); a leaked value in
21
+ // a log or a metrics row costs an actual PII exposure. This module is
22
+ // built to make the cheaper mistake.
23
+
24
+ // Order matters: more specific/longer patterns run first, so a token
25
+ // that could satisfy two shapes (e.g. a 16-digit run also containing a
26
+ // phone-shaped substring) is claimed by the more specific match before
27
+ // a looser pattern gets a chance to partially match what's left of it.
28
+ const PATTERNS = [
29
+ {
30
+ type: 'email',
31
+ regex: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g
32
+ },
33
+ {
34
+ type: 'credit_card',
35
+ // 13-19 digits, optionally grouped by single spaces or dashes
36
+ // between digits (covers both "4111111111111111" and
37
+ // "4111 1111 1111 1111"/"4111-1111-1111-1111"). Matched by shape
38
+ // first, then narrowed by a Luhn checksum below - shape alone would
39
+ // false-positive on any long unrelated digit run (an order id, a
40
+ // padded invoice number).
41
+ regex: /\b(?:\d[ -]?){12,18}\d\b/g,
42
+ validate: (match) => luhnValid(match.replace(/[ -]/g, ''))
43
+ },
44
+ {
45
+ type: 'ssn',
46
+ // US SSN: NNN-NN-NNNN. Dashes required - a bare 9-digit run is too
47
+ // easy to confuse with an account/phone number to redact safely
48
+ // without a much higher false-positive rate.
49
+ regex: /\b\d{3}-\d{2}-\d{4}\b/g
50
+ },
51
+ {
52
+ type: 'phone',
53
+ // NA-style, requiring at least one separator (space/dash/dot/
54
+ // parens) - a bare 10-digit run is left to the credit-card pattern
55
+ // above (which needs 13+ digits, so no real overlap) rather than
56
+ // guessed at here. Uses digit lookaround, not \b, at both ends: a
57
+ // leading "(" is itself a non-word character, so a \b right before
58
+ // it never matches (word-boundary needs one word char and one
59
+ // non-word char either side) - \b would let the engine skip past a
60
+ // real "(555)" opening paren and leave it un-redacted outside the
61
+ // match. (?<!\d)/(?!\d) only cares that the run isn't glued to more
62
+ // digits, which is what actually needs guarding against here.
63
+ regex: /(?<!\d)(?:\+?1[ .-]?)?\(?\d{3}\)?[ .-]\d{3}[ .-]\d{4}(?!\d)/g
64
+ },
65
+ {
66
+ type: 'api_key',
67
+ // Common vendor secret shapes (OpenAI classic "sk-...", OpenAI
68
+ // project-scoped "sk-proj-...", Anthropic "sk-ant-...", AWS,
69
+ // GitHub, Slack, Google) - opaque tokens like these are exactly the
70
+ // kind of thing that ends up pasted into a prompt by accident. The
71
+ // "sk-" body allows dashes/underscores, not just alphanumerics, so
72
+ // it covers the dash-separated "-proj-"/"-ant-" variants too rather
73
+ // than needing one alternative per vendor prefix.
74
+ regex: /\b(?:sk-[a-zA-Z0-9_-]{20,}|AKIA[0-9A-Z]{16}|gh[pousr]_[a-zA-Z0-9]{20,}|xox[baprs]-[a-zA-Z0-9-]{10,}|AIza[0-9A-Za-z_-]{35})\b/g
75
+ }
76
+ ];
77
+
78
+ function luhnValid(digits) {
79
+ let sum = 0;
80
+ let alternate = false;
81
+ for (let i = digits.length - 1; i >= 0; i--) {
82
+ let n = Number(digits[i]);
83
+ if (alternate) {
84
+ n *= 2;
85
+ if (n > 9) n -= 9;
86
+ }
87
+ sum += n;
88
+ alternate = !alternate;
89
+ }
90
+ return sum % 10 === 0;
91
+ }
92
+
93
+ function isEnabled() {
94
+ return process.env.GUARDRAILS_PII_REDACTION === 'true';
95
+ }
96
+
97
+ // redact(text) -> { text, redactions: [{ type, count }] }.
98
+ // Side-effect free and safe to call unconditionally - when the flag is
99
+ // off, returns the text UNCHANGED (not an error, not a throw), same
100
+ // "always callable, flag decides" shape as embeddings.js's isEnabled().
101
+ function redact(text) {
102
+ if (!isEnabled() || typeof text !== 'string' || text.length === 0) {
103
+ return { text, redactions: [] };
104
+ }
105
+
106
+ let result = text;
107
+ const counts = new Map();
108
+
109
+ for (const { type, regex, validate } of PATTERNS) {
110
+ // A fresh RegExp per pattern per call: the source patterns are
111
+ // global (/g), and a shared stateful regex's lastIndex would
112
+ // corrupt matching across concurrent/repeated calls in a
113
+ // long-running process handling many requests.
114
+ const re = new RegExp(regex.source, regex.flags);
115
+ result = result.replace(re, (match) => {
116
+ if (validate && !validate(match)) return match; // shape matched but failed validation - leave as-is
117
+ counts.set(type, (counts.get(type) || 0) + 1);
118
+ return `[REDACTED_${type.toUpperCase()}]`;
119
+ });
120
+ }
121
+
122
+ return {
123
+ text: result,
124
+ redactions: Array.from(counts.entries()).map(([type, count]) => ({ type, count }))
125
+ };
126
+ }
127
+
128
+ module.exports = { isEnabled, redact };
@@ -0,0 +1,194 @@
1
+ // model-router/providers/deepseek.js
2
+ //
3
+ // DeepSeek speaks the OpenAI wire format, so this is the `openai` SDK pointed
4
+ // at DeepSeek's own base URL - no second HTTP client, no hand-rolled fetch.
5
+ // What is NOT identical is the money: DeepSeek bills input in two tiers
6
+ // (cache hit vs cache miss) and every rate has a PEAK and an OFF-PEAK value,
7
+ // so a single flat rate table - the shape providers/openai.js can get away
8
+ // with - would misprice most requests. Both facts come from the vendor's own
9
+ // pages, read 2026-09-10:
10
+ // base URL + models + rates: api-docs.deepseek.com/quick_start/pricing
11
+ // usage fields: api-docs.deepseek.com/guides/kv_cache
12
+ //
13
+ // The review that prompted this file said "DeepSeek features incredibly cheap
14
+ // API calls and aggressive server-side prompt caching" and told the reader to
15
+ // "parse cached_tokens from DeepSeek's usage response blocks". The second half
16
+ // is wrong in a way that would have silently zeroed the savings: DeepSeek does
17
+ // not report OpenAI's nested prompt_tokens_details.cached_tokens, it reports a
18
+ // flat prompt_cache_hit_tokens / prompt_cache_miss_tokens pair. Cached input
19
+ // here is ~50x cheaper than a miss, so getting that mapping wrong is the
20
+ // difference between an accurate cost dashboard and a decorative one.
21
+ const { OpenAI } = require('openai');
22
+
23
+ const BASE_URL = process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com';
24
+
25
+ // USD per 1M tokens, PEAK rates. Off-peak is exactly half (vendor's own note:
26
+ // "Off-peak rates are half of the peak rates"). Peak = 01:00-04:00 and
27
+ // 06:00-10:00 UTC, Monday through Friday; everything else is off-peak.
28
+ const PEAK_RATES = {
29
+ 'deepseek-flash': { hit: 0.006, miss: 0.30, output: 1.20 },
30
+ 'deepseek-v4-pro': { hit: 0.044, miss: 1.32, output: 3.96 },
31
+ // Legacy names the API still accepts. The vendor states these are served by
32
+ // V4.1-Flash and BILLED AT THE FLASH PRICE, so they must not fall through to
33
+ // a "unknown model" default with different numbers.
34
+ 'deepseek-v4-flash': { hit: 0.006, miss: 0.30, output: 1.20 },
35
+ 'deepseek-v4-flash-vision-exp': { hit: 0.006, miss: 0.30, output: 1.20 }
36
+ };
37
+ // `deepseek-v4-pro` is being retired: from 2026-09-14 requests to it are routed
38
+ // to V4.1-Flash and billed as Flash. Until that date the pro rate is real, so
39
+ // both are priced and the switch happens on the vendor's side, not ours.
40
+ const DEFAULT_RATE = PEAK_RATES['deepseek-flash'];
41
+
42
+ function isPeak(now = new Date()) {
43
+ const day = now.getUTCDay(); // 0 = Sunday, 6 = Saturday
44
+ if (day === 0 || day === 6) return false;
45
+ const hour = now.getUTCHours();
46
+ return (hour >= 1 && hour < 4) || (hour >= 6 && hour < 10);
47
+ }
48
+
49
+ function rateFor(model, now = new Date()) {
50
+ const base = PEAK_RATES[model] || DEFAULT_RATE;
51
+ return isPeak(now) ? base : { hit: base.hit / 2, miss: base.miss / 2, output: base.output / 2 };
52
+ }
53
+
54
+ /**
55
+ * Cost for one call. Input tokens are SPLIT, not uniform: `cacheHitTokens` of
56
+ * them were served from DeepSeek's disk cache at the hit rate, the remainder at
57
+ * the miss rate. Getting this wrong is not a rounding error - at Flash's peak
58
+ * rates a fully-cached 1M-token input costs $0.006 instead of $0.30.
59
+ *
60
+ * The 3-argument form (no options) is kept working for callers that have no
61
+ * cache data: it prices everything as a miss, which is the conservative
62
+ * (never-understate) direction for a cost dashboard.
63
+ *
64
+ * `now` is injectable so tests can pin the billing window instead of passing
65
+ * or failing depending on what time of day the suite runs.
66
+ */
67
+ function estimateCost(model, inputTokens, outputTokens, { cacheHitTokens = 0, now = new Date() } = {}) {
68
+ const rate = rateFor(model, now);
69
+ const hit = Math.max(0, Math.min(cacheHitTokens || 0, inputTokens || 0));
70
+ const miss = Math.max(0, (inputTokens || 0) - hit);
71
+ return ((hit * rate.hit) + (miss * rate.miss) + ((outputTokens || 0) * rate.output)) / 1_000_000;
72
+ }
73
+
74
+ function buildClient(apiKey) {
75
+ return new OpenAI({
76
+ apiKey,
77
+ baseURL: BASE_URL,
78
+ // DeepSeek requires no extra headers; the SDK's defaults are fine.
79
+ timeout: Number(process.env.DEEPSEEK_TIMEOUT_MS) || 60000
80
+ });
81
+ }
82
+
83
+ function usageFrom(usage = {}) {
84
+ const input = usage.prompt_tokens || 0;
85
+ const hit = usage.prompt_cache_hit_tokens || 0;
86
+ const miss = typeof usage.prompt_cache_miss_tokens === 'number'
87
+ ? usage.prompt_cache_miss_tokens
88
+ : Math.max(0, input - hit);
89
+ return { input_tokens: input, output_tokens: usage.completion_tokens || 0, cache_hit_tokens: hit, cache_miss_tokens: miss };
90
+ }
91
+
92
+ async function chat(client, payload, options = {}) {
93
+ const request = {
94
+ model: payload.model,
95
+ messages: payload.messages,
96
+ temperature: typeof payload.temperature === 'number' ? payload.temperature : 0.0,
97
+ max_tokens: payload.max_tokens || 1024,
98
+ ...(payload.tools && { tools: payload.tools }),
99
+ ...(payload.tool_choice && { tool_choice: payload.tool_choice }),
100
+ ...(payload.response_format && { response_format: payload.response_format })
101
+ };
102
+
103
+ const start = Date.now();
104
+ const response = await client.chat.completions.create(request);
105
+ const latencyMs = Date.now() - start;
106
+
107
+ const choice = response.choices[0];
108
+ const usage = usageFrom(response.usage);
109
+ const costUsd = estimateCost(payload.model, usage.input_tokens, usage.output_tokens, { cacheHitTokens: usage.cache_hit_tokens });
110
+
111
+ return {
112
+ provider: 'deepseek',
113
+ model: payload.model,
114
+ latency_ms: latencyMs,
115
+ usage,
116
+ cost_usd: costUsd,
117
+ content: choice.message.content || '',
118
+ // Thinking-mode models (the default for the current models) return their
119
+ // chain separately. Passed through instead of dropped so a caller can see
120
+ // it, but NOT concatenated into `content` - that would corrupt every
121
+ // consumer that treats content as the answer.
122
+ reasoning_content: choice.message.reasoning_content,
123
+ tool_calls: choice.message.tool_calls,
124
+ raw: response
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Pure state-accumulation for one streamed chunk, factored out for the same
130
+ * reason as providers/openai.js's: usage/cost extraction is then unit-testable
131
+ * against canned chunks with no live API.
132
+ *
133
+ * DeepSeek only sends `usage` on the final chunk, and only when the request
134
+ * asked for it (`stream_options.include_usage`, set in chatStream below) -
135
+ * without that flag a streamed call carries no usage at all and the cost
136
+ * tracking this project is built around would sit at zero while looking fine.
137
+ */
138
+ function applyStreamChunk(state, chunk, onDelta) {
139
+ const choice = chunk.choices && chunk.choices[0];
140
+ if (choice && choice.delta) {
141
+ if (choice.delta.content) {
142
+ state.content += choice.delta.content;
143
+ onDelta(choice.delta.content);
144
+ }
145
+ if (choice.delta.reasoning_content) state.reasoningContent = (state.reasoningContent || '') + choice.delta.reasoning_content;
146
+ }
147
+ if (chunk.usage) {
148
+ state.inputTokens = chunk.usage.prompt_tokens || 0;
149
+ state.outputTokens = chunk.usage.completion_tokens || 0;
150
+ state.cacheHitTokens = chunk.usage.prompt_cache_hit_tokens || 0;
151
+ state.cacheMissTokens = chunk.usage.prompt_cache_miss_tokens;
152
+ }
153
+ }
154
+
155
+ async function chatStream(client, payload, { onDelta, signal } = {}) {
156
+ const request = {
157
+ model: payload.model,
158
+ messages: payload.messages,
159
+ temperature: typeof payload.temperature === 'number' ? payload.temperature : 0.0,
160
+ max_tokens: payload.max_tokens || 1024,
161
+ stream: true,
162
+ stream_options: { include_usage: true }
163
+ };
164
+
165
+ const start = Date.now();
166
+ const stream = await client.chat.completions.create(request, signal ? { signal } : undefined);
167
+
168
+ const state = { content: '', inputTokens: 0, outputTokens: 0, cacheHitTokens: 0, reasoningContent: '' };
169
+ for await (const chunk of stream) {
170
+ applyStreamChunk(state, chunk, onDelta || (() => {}));
171
+ }
172
+
173
+ const latencyMs = Date.now() - start;
174
+ const usage = usageFrom({
175
+ prompt_tokens: state.inputTokens,
176
+ completion_tokens: state.outputTokens,
177
+ prompt_cache_hit_tokens: state.cacheHitTokens,
178
+ ...(typeof state.cacheMissTokens === 'number' && { prompt_cache_miss_tokens: state.cacheMissTokens })
179
+ });
180
+ const costUsd = estimateCost(payload.model, usage.input_tokens, usage.output_tokens, { cacheHitTokens: usage.cache_hit_tokens });
181
+
182
+ return {
183
+ provider: 'deepseek',
184
+ model: payload.model,
185
+ latency_ms: latencyMs,
186
+ usage,
187
+ cost_usd: costUsd,
188
+ content: state.content,
189
+ reasoning_content: state.reasoningContent || undefined,
190
+ tool_calls: undefined
191
+ };
192
+ }
193
+
194
+ module.exports = { buildClient, chat, chatStream, applyStreamChunk, estimateCost, isPeak, rateFor, BASE_URL };
@@ -0,0 +1,62 @@
1
+ // model-router/providers/index.js
2
+ //
3
+ // The provider registry. Before this file, "which provider serves this model?"
4
+ // was answered by an if/else chain repeated in eight places across server.js
5
+ // (detection, two dispatch forks, the streaming client + stream-function
6
+ // forks, the cascade grader, a second tier branch, and a metrics label). Every
7
+ // one of them knew exactly two providers, so adding a third meant finding and
8
+ // patching all eight - and missing one produced a weird failure far from the
9
+ // edit, not an error at the place that was forgotten.
10
+ //
11
+ // Adding a provider is now: write providers/<name>.js to the contract
12
+ // (buildClient/chat/chatStream/estimateCost), require it here, give it a model
13
+ // prefix and an env key. Everything else routes through this file.
14
+ const anthropic = require('./anthropic');
15
+ const openai = require('./openai');
16
+ const deepseek = require('./deepseek');
17
+ const openrouter = require('./openrouter');
18
+
19
+ const PROVIDERS = { anthropic, openai, deepseek, openrouter };
20
+
21
+ // The env var holding each provider's key. Used for both presence checks and
22
+ // the "not configured" error message, so those two can never disagree about
23
+ // which variable a provider actually needs.
24
+ const ENV_KEYS = {
25
+ anthropic: 'ANTHROPIC_API_KEY',
26
+ openai: 'OPENAI_API_KEY',
27
+ deepseek: 'DEEPSEEK_API_KEY',
28
+ openrouter: 'OPENROUTER_API_KEY'
29
+ };
30
+
31
+ /**
32
+ * Which provider serves this model? Returns null when nothing claims it, which
33
+ * callers turn into a 400 "Unsupported model" - the same answer as before for
34
+ * anything unmatched.
35
+ *
36
+ * ORDER MATTERS: OpenRouter is checked first because its ids are
37
+ * `vendor/model`, and `deepseek/deepseek-chat` must not be captured by the
38
+ * direct DeepSeek prefix. (The direct DeepSeek ids are `deepseek-flash` /
39
+ * `deepseek-v4-pro` - no slash - so the two never actually collide, but the
40
+ * ordering is what guarantees that stays true if either vendor renames.)
41
+ */
42
+ function detectProvider(model) {
43
+ if (typeof model !== 'string' || !model) return null;
44
+ if (openrouter.isOpenRouterModel(model)) return 'openrouter';
45
+ if (model.startsWith('claude-')) return 'anthropic';
46
+ if (model.startsWith('deepseek-')) return 'deepseek';
47
+ if (model.startsWith('gpt-') || model.startsWith('o1') || model.startsWith('o3')) return 'openai';
48
+ return null;
49
+ }
50
+
51
+ function get(name) { return PROVIDERS[name] || null; }
52
+
53
+ function envKey(name) { return ENV_KEYS[name] || null; }
54
+
55
+ function names() { return Object.keys(PROVIDERS); }
56
+
57
+ /** Provider names that look usable given the current environment (presence only, never values). */
58
+ function configured(env = process.env) {
59
+ return names().filter((n) => !!env[ENV_KEYS[n]]);
60
+ }
61
+
62
+ module.exports = { PROVIDERS, ENV_KEYS, detectProvider, get, envKey, names, configured };
@@ -15,7 +15,14 @@ function estimateCost(model, inputTokens, outputTokens) {
15
15
  return ((inputTokens * rate.input) + (outputTokens * rate.output)) / 1_000_000;
16
16
  }
17
17
 
18
- async function chat(client, payload) {
18
+ // `options.requestLogprobs` (cascade routing, step 34): ask the API for
19
+ // per-token logprobs so cascade.js can estimate confidence from the response.
20
+ // ONLY set by the router for an OpenAI candidate when cascade is active for
21
+ // that dispatch - a normal caller never sees this, and it's near-zero extra
22
+ // cost on the request it's attached to. top_logprobs: 1 keeps the payload
23
+ // small (one alternative per token) while still carrying the emitted token's
24
+ // own logprob, which is all the confidence math needs.
25
+ async function chat(client, payload, options = {}) {
19
26
  const request = {
20
27
  model: payload.model,
21
28
  messages: payload.messages,
@@ -23,7 +30,8 @@ async function chat(client, payload) {
23
30
  max_tokens: payload.max_tokens || 1024,
24
31
  ...(payload.tools && { tools: payload.tools }),
25
32
  ...(payload.tool_choice && { tool_choice: payload.tool_choice }),
26
- ...(payload.response_format && { response_format: payload.response_format })
33
+ ...(payload.response_format && { response_format: payload.response_format }),
34
+ ...(options.requestLogprobs && { logprobs: true, top_logprobs: 1 })
27
35
  };
28
36
 
29
37
  const start = Date.now();