cachegate 1.4.1 → 1.4.2

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 CHANGED
@@ -78,6 +78,21 @@ ANTHROPIC_API_KEY=sk-ant-api03-...
78
78
  # explicitly distinct from whatever else reads DATABASE_URL in your stack.
79
79
  # MEMOCODE_ROUTER_DATABASE_URL=postgres://user:pass@host:5432/dbname
80
80
 
81
+ # Optional: TLS verification for the Postgres connection above, if its
82
+ # certificate isn't signed by a publicly-trusted CA (most managed
83
+ # Postgres - RDS, Neon, Supabase - needs neither of these; it just works).
84
+ # Some providers issue a self-signed certificate per database instance
85
+ # instead (confirmed: Render's managed Postgres is one) - set PGSSL_CA to
86
+ # that CA's own PEM content, or PGSSL_CA_PATH to a file containing it.
87
+ # PGSSL_CA=-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----
88
+ # PGSSL_CA_PATH=/path/to/ca.pem
89
+ #
90
+ # PGSSL_INSECURE=true turns TLS verification off entirely (encrypted, but
91
+ # the server's identity is never checked) - an explicit escape hatch for a
92
+ # deployment that can't set either of the above right now, not a normal
93
+ # setting. Logs a warning on every use so it can't happen silently.
94
+ # PGSSL_INSECURE=true
95
+
81
96
  # Optional: where JSONL metrics files live, if Postgres isn't
82
97
  # configured. Despite the name, this now names a DIRECTORY (kept for
83
98
  # backward compatibility with older configs that pointed it at a
package/README.md CHANGED
@@ -183,8 +183,8 @@ the folder. Pick **standalone** if cachegate should be one shared
183
183
  service serving multiple apps (or you'd rather manage it as its own
184
184
  deployable thing). Pick **colocated** if you want everything - your app
185
185
  plus its router - in one repo, one place to look, no second project to
186
- maintain (this is exactly how this router lives inside MemoCode's own
187
- monorepo today).
186
+ maintain (this is exactly how this router lives inside at least one
187
+ production app's own monorepo today).
188
188
 
189
189
  **Reaching it once it's running:**
190
190
  - Same machine, calling app not containerized: `http://localhost:4000`.
@@ -616,9 +616,9 @@ positioning. A few things worth knowing before relying on it:
616
616
  no database of its own, but ephemeral on most hosts (a restart/
617
617
  redeploy wipes a container's own filesystem). Set `DATABASE_URL` to a
618
618
  real Postgres connection string and every metrics function
619
- transparently reads/writes there instead - MemoCode's own embedded
620
- deployment points this at its already-provisioned `memocode-db`
621
- rather than standing up a separate database just for cost history.
619
+ transparently reads/writes there instead - a real embedded deployment
620
+ can point this at its own already-provisioned database rather than
621
+ standing up a separate one just for cost history.
622
622
  Same public API either way (`record`/`readRecent`/`providerStats`/
623
623
  `rangeSummary`/`pruneOlderThan`); nothing outside `metrics.js` needs
624
624
  to know or care which backend is actually running. Retention is the
@@ -0,0 +1,28 @@
1
+ [
2
+ { "a": "Summarize this document for me", "b": "Can you give me a summary of this document?", "expectMatch": true, "note": "paraphrase" },
3
+ { "a": "What's the capital of France?", "b": "Tell me the capital city of France", "expectMatch": true, "note": "paraphrase" },
4
+ { "a": "Translate this sentence to Spanish", "b": "Please translate this sentence into Spanish", "expectMatch": true, "note": "paraphrase" },
5
+ { "a": "How do I reverse a list in Python?", "b": "What's the Python way to reverse a list?", "expectMatch": true, "note": "paraphrase" },
6
+ { "a": "Explain quantum entanglement simply", "b": "Can you explain quantum entanglement in simple terms?", "expectMatch": true, "note": "paraphrase" },
7
+ { "a": "Write a haiku about autumn", "b": "Compose a haiku on the theme of autumn", "expectMatch": true, "note": "paraphrase" },
8
+ { "a": "What are the health benefits of green tea?", "b": "Tell me about the health benefits of drinking green tea", "expectMatch": true, "note": "paraphrase" },
9
+ { "a": "Fix this bug in my code", "b": "Can you help me fix this bug in my code?", "expectMatch": true, "note": "paraphrase" },
10
+ { "a": "Give me a recipe for chocolate chip cookies", "b": "What's a good chocolate chip cookie recipe?", "expectMatch": true, "note": "paraphrase" },
11
+ { "a": "Explain the difference between TCP and UDP", "b": "What's the difference between TCP and UDP protocols?", "expectMatch": true, "note": "paraphrase" },
12
+
13
+ { "a": "What is 15% of 200?", "b": "What is 25% of 900?", "expectMatch": false, "note": "same template, different number - the exact failure mode Step 21 caught on the literal-cache side" },
14
+ { "a": "Summarize invoice #12345, total $500", "b": "Summarize invoice #67890, total $9000", "expectMatch": false, "note": "same template, different value" },
15
+ { "a": "What happened on 2024-01-15?", "b": "What happened on 2024-03-22?", "expectMatch": false, "note": "same template, different date" },
16
+ { "a": "Convert 100 USD to EUR", "b": "Convert 500 USD to EUR", "expectMatch": false, "note": "same template, different amount" },
17
+ { "a": "What's the population of Canada?", "b": "What's the population of Brazil?", "expectMatch": false, "note": "same template, different subject" },
18
+ { "a": "Summarize the plot of Romeo and Juliet", "b": "Summarize the plot of Hamlet", "expectMatch": false, "note": "same template, different subject" },
19
+ { "a": "How many calories are in a banana?", "b": "How many calories are in an avocado?", "expectMatch": false, "note": "same template, different subject" },
20
+ { "a": "What year did World War II end?", "b": "What year did World War I end?", "expectMatch": false, "note": "same template, different subject" },
21
+
22
+ { "a": "Summarize this document for me", "b": "Translate this sentence to Spanish", "expectMatch": false, "note": "unrelated" },
23
+ { "a": "What's the capital of France?", "b": "Write a haiku about autumn", "expectMatch": false, "note": "unrelated" },
24
+ { "a": "Fix this bug in my code", "b": "Give me a recipe for chocolate chip cookies", "expectMatch": false, "note": "unrelated" },
25
+ { "a": "Explain quantum entanglement simply", "b": "What's the difference between TCP and UDP protocols?", "expectMatch": false, "note": "unrelated" },
26
+ { "a": "How do I reverse a list in Python?", "b": "What are the health benefits of green tea?", "expectMatch": false, "note": "unrelated" },
27
+ { "a": "What is 15% of 200?", "b": "Summarize the plot of Romeo and Juliet", "expectMatch": false, "note": "unrelated" }
28
+ ]
@@ -0,0 +1,82 @@
1
+ // model-router/eval/semantic-cache-eval.js
2
+ //
3
+ // Phase 2 roadmap, step 22.1/22.3 - measures precision/recall of
4
+ // whichever `embeddings` backend is active, against semanticCache.js's
5
+ // OWN cosineSimilarity() + threshold, using a curated positive/negative
6
+ // prompt-pair set (semantic-cache-eval-pairs.json). This is an offline
7
+ // measurement tool, not the request path and not run in CI - "ship only
8
+ // after a real eval pass" (the moat doc's own words on this step) means
9
+ // a human reads this report and decides, not a pass/fail gate that
10
+ // silently blocks a build.
11
+ //
12
+ // Parameterized on the embeddings module (defaults to whichever one
13
+ // ../embeddings currently resolves to) so the SAME dataset and harness
14
+ // run unchanged against OpenAI (today's default) or the local model
15
+ // (once step 22.2 lands - set SEMANTIC_CACHE_LOCAL_EMBEDDINGS=true and
16
+ // re-run) - a real apples-to-apples comparison, not two different
17
+ // measurements taken two different ways.
18
+ //
19
+ // Run: npm run eval:semantic-cache
20
+ // (needs a real, enabled embeddings backend - OPENAI_API_KEY today)
21
+
22
+ const { cosineSimilarity, DEFAULT_THRESHOLD } = require('../semanticCache');
23
+ const defaultPairs = require('./semantic-cache-eval-pairs.json');
24
+
25
+ async function runEval({
26
+ embeddings = require('../embeddings'),
27
+ threshold = DEFAULT_THRESHOLD,
28
+ pairs = defaultPairs
29
+ } = {}) {
30
+ if (!embeddings.isEnabled()) {
31
+ throw new Error(
32
+ 'No embeddings backend is enabled - set OPENAI_API_KEY (or, once available, ' +
33
+ 'SEMANTIC_CACHE_LOCAL_EMBEDDINGS=true) before running this eval.'
34
+ );
35
+ }
36
+
37
+ let tp = 0;
38
+ let fp = 0;
39
+ let tn = 0;
40
+ let fn = 0;
41
+ const misclassified = [];
42
+
43
+ for (const pair of pairs) {
44
+ const [vecA, vecB] = await Promise.all([embeddings.embed(pair.a), embeddings.embed(pair.b)]);
45
+ const similarity = cosineSimilarity(vecA, vecB);
46
+ const predictedMatch = similarity >= threshold;
47
+
48
+ if (pair.expectMatch && predictedMatch) {
49
+ tp++;
50
+ } else if (pair.expectMatch && !predictedMatch) {
51
+ fn++;
52
+ misclassified.push({ ...pair, similarity, predictedMatch });
53
+ } else if (!pair.expectMatch && predictedMatch) {
54
+ fp++;
55
+ misclassified.push({ ...pair, similarity, predictedMatch });
56
+ } else {
57
+ tn++;
58
+ }
59
+ }
60
+
61
+ // null (not NaN, not a misleading 1/0) when there's nothing to be
62
+ // precise/complete about - e.g. a dataset with zero actual positives
63
+ // makes "recall" an undefined question, not a score of 0.
64
+ const precision = tp + fp > 0 ? tp / (tp + fp) : null;
65
+ const recall = tp + fn > 0 ? tp / (tp + fn) : null;
66
+
67
+ return { threshold, total: pairs.length, tp, fp, tn, fn, precision, recall, misclassified };
68
+ }
69
+
70
+ if (require.main === module) {
71
+ runEval()
72
+ .then((result) => {
73
+ console.log(JSON.stringify(result, null, 2));
74
+ process.exit(0);
75
+ })
76
+ .catch((err) => {
77
+ console.error('Eval failed:', err.message);
78
+ process.exit(1);
79
+ });
80
+ }
81
+
82
+ module.exports = { runEval };
package/metrics.js CHANGED
@@ -42,31 +42,74 @@ const { Pool } = require('pg');
42
42
  // file storage above/below stays the default for exactly the reason
43
43
  // its own original comment gives: self-hosted/lightweight, zero new
44
44
  // infrastructure required to run this router standalone in some other
45
- // app. But the EMBEDDED deployment inside MemoCode specifically already
46
- // has a real Postgres database (memocode-db, provisioned for its own
47
- // user-account/library data regardless of this router) - reusing that
48
- // costs nothing new (no extra service, no extra bill, no extra account)
49
- // and, unlike the router's own container filesystem, genuinely survives
50
- // a restart/redeploy. DATABASE_URL is Render's own standard convention
51
- // for injecting a database's connection string (matches how
52
- // 000_backend/db.mjs reads the exact same variable for the exact same
53
- // reason) - set it and every function below transparently reads/writes
54
- // Postgres instead of local files; leave it unset and nothing here
55
- // changes at all.
45
+ // app. But an embedded deployment (this engine running inside another
46
+ // app's own backend) often already has a real Postgres database,
47
+ // provisioned for its own user-account/library data regardless of this
48
+ // router - reusing that costs nothing new (no extra service, no extra
49
+ // bill, no extra account) and, unlike the router's own container
50
+ // filesystem, genuinely survives a restart/redeploy. DATABASE_URL is
51
+ // Render's own standard convention for injecting a database's
52
+ // connection string (matches how a sibling backend's own db.mjs reads
53
+ // the exact same variable for the exact same reason) - set it and every
54
+ // function below transparently reads/writes Postgres instead of local
55
+ // files; leave it unset and nothing here changes at all.
56
56
  function usingPostgres() {
57
57
  return Boolean(process.env.DATABASE_URL || process.env.MEMOCODE_ROUTER_DATABASE_URL);
58
58
  }
59
59
 
60
+ // PGSSL_CA / PGSSL_CA_PATH: opt-in custom CA for verifying a hosted
61
+ // Postgres whose certificate isn't signed by a publicly-trusted root - a
62
+ // self-signed cert some managed providers issue per database instance
63
+ // (confirmed live, 2026-09-13: Render's managed Postgres is one). PGSSL_CA
64
+ // takes the certificate's own PEM content directly (handy on hosts that
65
+ // inject env vars but not files); PGSSL_CA_PATH reads it from a file
66
+ // instead (handy when it's already committed/mounted as one). Neither set
67
+ // means the default Node trust store alone - correct and sufficient for
68
+ // any provider whose cert chains to a public root (RDS, Neon, Supabase,
69
+ // most managed Postgres).
70
+ function resolvePgCa() {
71
+ if (process.env.PGSSL_CA) return process.env.PGSSL_CA;
72
+ if (process.env.PGSSL_CA_PATH) {
73
+ try {
74
+ return fs.readFileSync(process.env.PGSSL_CA_PATH, 'utf8');
75
+ } catch (err) {
76
+ throw new Error(`PGSSL_CA_PATH is set to "${process.env.PGSSL_CA_PATH}" but could not be read: ${err.message}`);
77
+ }
78
+ }
79
+ return null;
80
+ }
81
+
82
+ // Review finding: this used to be a hardcoded { rejectUnauthorized: false }
83
+ // - TLS encrypted the connection but never verified WHO it was talking to,
84
+ // for every deployment, with no way to opt into real verification even
85
+ // with the right CA in hand. Default is now secure (rejectUnauthorized:
86
+ // true, the public trust store) - correct out of the box for any provider
87
+ // on a publicly-trusted CA. A provider whose cert isn't (self-signed,
88
+ // issued per-instance) needs PGSSL_CA or PGSSL_CA_PATH set to that CA -
89
+ // see resolvePgCa() above. PGSSL_INSECURE=true is the explicit, loud
90
+ // escape hatch for a deployment that can't do either right now: the same
91
+ // old behavior, but it can no longer happen by accident or silently.
92
+ function pgSslConfig(connectionString) {
93
+ if (!connectionString || /localhost|127\.0\.0\.1/.test(connectionString)) return false;
94
+ if (process.env.PGSSL_INSECURE === 'true') {
95
+ console.warn(
96
+ "⚠️ PGSSL_INSECURE=true: Postgres TLS certificate verification is OFF. " +
97
+ "This connection is encrypted but the server's identity is NOT checked. " +
98
+ 'Set PGSSL_CA or PGSSL_CA_PATH instead as soon as you can.'
99
+ );
100
+ return { rejectUnauthorized: false };
101
+ }
102
+ const ca = resolvePgCa();
103
+ return ca ? { ca, rejectUnauthorized: true } : { rejectUnauthorized: true };
104
+ }
105
+
60
106
  let pgPool = null;
61
107
  function getPool() {
62
108
  if (!pgPool) {
63
109
  const connectionString = process.env.MEMOCODE_ROUTER_DATABASE_URL || process.env.DATABASE_URL;
64
110
  pgPool = new Pool({
65
111
  connectionString,
66
- // Same rule db.mjs already uses: a real hosted Postgres (Render's
67
- // managed instance) needs SSL; a local one (dev, this module's
68
- // own tests) doesn't and would just fail the handshake if asked.
69
- ssl: connectionString && !/localhost|127\.0\.0\.1/.test(connectionString) ? { rejectUnauthorized: false } : false
112
+ ssl: pgSslConfig(connectionString)
70
113
  });
71
114
  }
72
115
  return pgPool;
@@ -79,10 +122,11 @@ function getPool() {
79
122
  // NULLABLE, not NOT NULL - deliberately different from how a fork
80
123
  // starting fresh (CREATE TABLE with a required column) would do it.
81
124
  // This module has live deployments already running against an existing
82
- // table (MemoCode's own render wiring) where CREATE TABLE IF NOT EXISTS
83
- // is a no-op on an already-created table - ADD COLUMN IF NOT EXISTS is
84
- // what actually reaches an existing table's schema (same ALTER pattern
85
- // the sibling backend's db.mjs already uses for exactly this reason).
125
+ // table (a real embedded deployment's own Render wiring) where CREATE
126
+ // TABLE IF NOT EXISTS is a no-op on an already-created table - ADD
127
+ // COLUMN IF NOT EXISTS is what actually reaches an existing table's
128
+ // schema (same ALTER pattern a sibling backend's own db.mjs already
129
+ // uses for exactly this reason).
86
130
  // Existing rows get scope = NULL, which is exactly right: they were
87
131
  // recorded before scope existed, under the one global/unscoped history,
88
132
  // and null-scope reads (see rowFilterSql below) return precisely that
@@ -496,8 +540,8 @@ async function providerStats(scope, windowSize = 50) {
496
540
  * duplicated logic.
497
541
  */
498
542
 
499
- // Shared "$ saved" formula (mirrors cachegate-cloud's usage.mjs
500
- // estimatedSavings / estimatedSavingsGlobal, 2026-09-05 Phase 2 step 20):
543
+ // Shared "$ saved" formula (mirrors a wrapping deployment's own usage.mjs
544
+ // estimatedSavings / estimatedSavingsGlobal):
501
545
  // for each model, the average cost of a cache-MISS (cache_hit === false,
502
546
  // no error) times that model's cache-HIT count, summed across models. A
503
547
  // model with hits but no recorded miss yet contributes 0 - never a
@@ -739,5 +783,6 @@ module.exports = {
739
783
  usingPostgres,
740
784
  matchesScope,
741
785
  closePostgresPoolForTests,
786
+ pgSslConfig,
742
787
  DATA_DIR
743
788
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cachegate",
3
- "version": "1.4.1",
3
+ "version": "1.4.2",
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",
@@ -24,7 +24,9 @@
24
24
  "tracing.js",
25
25
  "providers/",
26
26
  "public/",
27
- ".env.example"
27
+ ".env.example",
28
+ "eval/semantic-cache-eval.js",
29
+ "eval/semantic-cache-eval-pairs.json"
28
30
  ],
29
31
  "engines": {
30
32
  "node": ">=18.0.0"
package/redisClient.js CHANGED
@@ -42,12 +42,12 @@ const ready = new Promise((resolve) => { readyResolve = resolve; });
42
42
  // including the automatic-reconnect loop after the socket dies - gating
43
43
  // cache reads/writes on it lets every command queue on a dead socket and
44
44
  // hang the request instead of failing open. `isReady` is true only when
45
- // a command can actually execute right now. Live-verified in the
46
- // Cachegate Cloud build (its PR #12 review): with isOpen, a stopped
47
- // Redis hung /v1 requests for 2+ minutes; with isReady the same request
48
- // returned in 11ms, correctly skipping the cache. This is the backport
49
- // of that fix - the cloud vendored this engine and fixed its copy first;
50
- // the engine and the public cachegate repo still shipped the bug.
45
+ // a command can actually execute right now. Live-verified in a real
46
+ // hosted deployment: with isOpen, a stopped Redis hung /v1 requests for
47
+ // 2+ minutes; with isReady the same request returned in 11ms, correctly
48
+ // skipping the cache. This is the backport of that fix - a downstream
49
+ // vendored copy of this engine caught and fixed it first; this repo
50
+ // still shipped the bug until now.
51
51
  function isConnected() {
52
52
  return client.isReady;
53
53
  }
package/router.js CHANGED
@@ -15,8 +15,7 @@
15
15
  // exactly as before, unchanged, in server.js. Nothing here overrides an
16
16
  // explicit choice - virtual models are opt-in.
17
17
 
18
- const anthropicProvider = require('./providers/anthropic');
19
- const openaiProvider = require('./providers/openai');
18
+ const providers = require('./providers');
20
19
  const metrics = require('./metrics');
21
20
 
22
21
  // A tier groups equivalent-capability models across providers - the
@@ -90,8 +89,18 @@ function loadStrategy() {
90
89
  return raw;
91
90
  }
92
91
 
92
+ // Normalizes an estimated cost for comparison: a real number sorts on its
93
+ // own value, anything else (null - an estimator that ran but couldn't price
94
+ // this model, e.g. OpenRouter on an id it has no rate for - or a missing
95
+ // estimator entirely) sorts LAST. Found in review: `a.estimatedCostUsd -
96
+ // b.estimatedCostUsd` on a `null` silently coerces to `0 - b`, which ranks
97
+ // an UNKNOWN price as free - the opposite of "unknown, so don't prefer it."
98
+ function comparableCost(candidate) {
99
+ return typeof candidate.estimatedCostUsd === 'number' ? candidate.estimatedCostUsd : Infinity;
100
+ }
101
+
93
102
  function byCostAscending(a, b) {
94
- return a.estimatedCostUsd - b.estimatedCostUsd;
103
+ return comparableCost(a) - comparableCost(b);
95
104
  }
96
105
 
97
106
  function byLatencyThenCost(a, b) {
@@ -130,10 +139,15 @@ function isVirtualModel(model) {
130
139
  return typeof model === 'string' && model.startsWith('router:');
131
140
  }
132
141
 
142
+ // Routes through the provider registry (providers/index.js) instead of
143
+ // naming providers here, so a new provider needs no edit in this file to be
144
+ // cost-ranked. Found in review: this hardcoded anthropic/openai only, so
145
+ // adding deepseek/openrouter to the registry never wired them into cost
146
+ // estimation - every candidate on either got `null` here -> Infinity below
147
+ // -> always ranked last, silently defeating the point of adding them.
133
148
  function estimatorFor(provider) {
134
- if (provider === 'anthropic') return anthropicProvider.estimateCost;
135
- if (provider === 'openai') return openaiProvider.estimateCost;
136
- return null;
149
+ const mod = providers.get(provider);
150
+ return mod ? mod.estimateCost : null;
137
151
  }
138
152
 
139
153
  // A fixed token assumption used ONLY to compare candidates against each
package/server.js CHANGED
@@ -91,11 +91,11 @@ function redactMessages(messages) {
91
91
  const app = express();
92
92
  // Step 36 (observability): initialize OTel once, at module load. A no-op
93
93
  // unless OTEL_ENABLED + OTEL_EXPORTER_OTLP_ENDPOINT are both set (see
94
- // tracing.js) - so both the standalone server and a wrapping deployment
95
- // (cachegate-cloud's cloud-server.js) get tracing without any extra call.
94
+ // tracing.js) - so both the standalone server and a wrapping deployment's
95
+ // own entry point get tracing without any extra call.
96
96
  tracing.initTracing();
97
97
  // `trust proxy`, now CONFIGURABLE and secure by default. The previous hardcoded `1` was chosen for
98
- // Cachegate Cloud's single-hop topology (2026-09-04) and is right THERE, but it is the wrong default
98
+ // one specific hosted deployment's single-hop topology (2026-09-04) and is right THERE, but it is the wrong default
99
99
  // for the topology this engine's own README documents (`docker run -p 4000:4000`: no proxy at all).
100
100
  // In that topology `1` trusts a client-controlled X-Forwarded-For, so any caller can present a fresh
101
101
  // IP on every request and the per-IP limiter on the key-holding routes is defeated - fail-OPEN, and
@@ -103,9 +103,9 @@ tracing.initTracing();
103
103
  // away from correct. Every other security decision in this file fails closed (no DATABASE_URL, no
104
104
  // API_KEY_ENCRYPTION_SECRET, auth) and this should not be the exception.
105
105
  //
106
- // Deployment note: a proxied deployment MUST now set TRUST_PROXY explicitly - Cachegate Cloud sets
107
- // TRUST_PROXY=1 in render.yaml and RENDER-ENV-MAP.md. Upgrade impact is a boot-time warning on Render
108
- // (see below) plus a CHANGELOG entry, not a silent change of limiter scope.
106
+ // Deployment note: a proxied deployment MUST now set TRUST_PROXY explicitly - a real deployment
107
+ // behind Render's proxy sets TRUST_PROXY=1 in its own render.yaml. Upgrade impact is a boot-time
108
+ // warning on Render (see below) plus a CHANGELOG entry, not a silent change of limiter scope.
109
109
  function resolveTrustProxy(raw) {
110
110
  const value = raw == null ? '' : String(raw).trim();
111
111
  if (value === '') {
@@ -145,8 +145,8 @@ app.set('trust proxy', resolveTrustProxy(process.env.TRUST_PROXY));
145
145
  // benefit).
146
146
  app.disable('x-powered-by');
147
147
 
148
- // Defense-in-depth backstop, the same treatment the MemoCode backend
149
- // already carries (its PRs #67/#71): Express 4 does NOT route an async
148
+ // Defense-in-depth backstop, the same treatment a wrapping backend deployment
149
+ // already carries: Express 4 does NOT route an async
150
150
  // route handler's rejected promise to the error middleware at the bottom
151
151
  // of this file - left unguarded, Node's default since v15 is to crash
152
152
  // the whole process, taking every other in-flight request with it. The
@@ -203,7 +203,7 @@ function constantTimeEqual(a, b) {
203
203
  // A deployer needing real multi-tenancy (issued-key auth instead of one
204
204
  // shared internal key, per-tenant BYOK provider keys, per-tenant rate
205
205
  // limiting) overrides these via configure() below instead of forking
206
- // this file - the fork this project's own Cachegate Cloud build had to
206
+ // this file - the fork a real hosted deployment had to
207
207
  // maintain until now, duplicating every one of these decisions across a
208
208
  // full copy of server.js. Every default here is EXACTLY today's
209
209
  // single-tenant, unconfigured behavior - never calling configure()
@@ -231,7 +231,7 @@ const seams = {
231
231
  // today's single shared process.env key, the same for every scope. A
232
232
  // BYOK-style deployer overrides this to look the key up per-scope
233
233
  // instead - and since a real per-scope lookup is usually a database
234
- // read (Cachegate Cloud's is a Postgres fetch + decrypt), the resolver
234
+ // read (a real BYOK deployment's is typically a Postgres fetch + decrypt), the resolver
235
235
  // may return a Promise; every call site below awaits it, which is a
236
236
  // no-op for a synchronous resolver, so both shapes are first-class.
237
237
  // (See the callers below: they never cache a client built from a
@@ -277,11 +277,11 @@ async function requireInternalKey(req, res, next) {
277
277
  // internal key) has no ceiling today. Defaults are deliberately
278
278
  // generous for real usage and overridable per deployment.
279
279
  //
280
- // The embedded deployment (this app's own MemoCode instance) has
281
- // exactly ONE caller identity - memocode-backend, one service, one
282
- // outbound IP - which means express-rate-limit's default per-IP
283
- // keying doesn't separate individual end users at all: this ceiling is
284
- // shared across EVERY MemoCode user's combined traffic, not per person.
280
+ // An embedded deployment (this engine running inside another app's own
281
+ // backend) has exactly ONE caller identity - one service, one outbound
282
+ // IP - which means express-rate-limit's default per-IP keying doesn't
283
+ // separate individual end users at all: this ceiling is shared across
284
+ // EVERY end user's combined traffic, not per person.
285
285
  // 60/60s (the original default) turned out to be uncomfortably close
286
286
  // to what a single legitimate action can burst on its own: PDF
287
287
  // summarize dispatches one call per chapter, sequentially, up to
@@ -297,8 +297,8 @@ async function requireInternalKey(req, res, next) {
297
297
  // multi-tenancy step the router's own docs already flag as future
298
298
  // scope (see ROADMAP.md's embedded/standalone split), not something
299
299
  // this single-app deployment needs yet. Note for a STANDALONE
300
- // self-hoster (as opposed to MemoCode's own single-caller embedded
301
- // deployment the paragraph above describes): if your own callers each
300
+ // self-hoster (as opposed to the single-caller embedded deployment
301
+ // the paragraph above describes): if your own callers each
302
302
  // have distinct outbound IPs, this same per-IP default DOES separate
303
303
  // them from each other - the "shared ceiling" caveat above is specific
304
304
  // to a deployment with exactly one caller identity, not a general
@@ -312,12 +312,20 @@ async function requireInternalKey(req, res, next) {
312
312
  // (ipKeyGenerator) when never configured - not a bare `req.ip`, which
313
313
  // the library itself warns can let IPv6 users bypass limits (same
314
314
  // default it would have used had this option been omitted entirely).
315
+ // Shared by every limiter in this file - review finding: readEndpointLimiter
316
+ // below was defined without this at all, silently falling back to
317
+ // express-rate-limit's OWN default, which is the bare `req.ip` the comment
318
+ // above warns about - the exact IPv6 bypass this function exists to avoid,
319
+ // present on /stats and /dashboard/data while /v1 was fixed. Pulled out
320
+ // once so the two limiters can no longer drift apart on this again.
321
+ const ipv6SafeKeyGenerator = (req, res) => (seams.rateLimitKeyGenerator ? seams.rateLimitKeyGenerator(req, res) : rateLimit.ipKeyGenerator(req.ip));
322
+
315
323
  const rateLimiter = rateLimit({
316
324
  windowMs: Number(process.env.RATE_LIMIT_WINDOW_MS) || 60_000,
317
325
  limit: Number(process.env.RATE_LIMIT_MAX) || 300,
318
326
  standardHeaders: true,
319
327
  legacyHeaders: false,
320
- keyGenerator: (req, res) => (seams.rateLimitKeyGenerator ? seams.rateLimitKeyGenerator(req, res) : rateLimit.ipKeyGenerator(req.ip)),
328
+ keyGenerator: ipv6SafeKeyGenerator,
321
329
  message: { error: 'Too many requests - rate limit exceeded' }
322
330
  });
323
331
 
@@ -334,6 +342,7 @@ const readEndpointLimiter = rateLimit({
334
342
  limit: Number(process.env.READ_RATE_LIMIT_MAX) || 120,
335
343
  standardHeaders: true,
336
344
  legacyHeaders: false,
345
+ keyGenerator: ipv6SafeKeyGenerator,
337
346
  message: { error: 'Too many requests - rate limit exceeded' }
338
347
  });
339
348
 
@@ -462,10 +471,16 @@ app.get('/stats', requireInternalKey, readEndpointLimiter, async (req, res) => {
462
471
  // between them - moved here from the public GET /health (security-
463
472
  // review finding, 2026-09-02): this endpoint already requires the
464
473
  // internal key, /health never did.
465
- providers: {
466
- anthropic: Boolean(await seams.resolveProviderKey(req.scope, 'anthropic')),
467
- openai: Boolean(await seams.resolveProviderKey(req.scope, 'openai'))
468
- },
474
+ // Review finding: this hardcoded anthropic/openai only, so adding
475
+ // deepseek/openrouter to the registry never surfaced them here -
476
+ // an operator checking "which providers have a key configured"
477
+ // saw a false "no" for either. Built from providers.names() now,
478
+ // so a future provider needs no edit here to show up.
479
+ providers: Object.fromEntries(
480
+ await Promise.all(
481
+ providers.names().map(async (name) => [name, Boolean(await seams.resolveProviderKey(req.scope, name))])
482
+ )
483
+ ),
469
484
  routing_tiers: Object.keys(router.loadTiers()),
470
485
  routing_strategy: router.loadStrategy()
471
486
  });
@@ -1241,7 +1256,10 @@ if (require.main === module) {
1241
1256
  }
1242
1257
  app.listen(PORT, () => {
1243
1258
  console.log(`🚀 cachegate listening on port ${PORT}`);
1244
- console.log(`📡 Providers: Anthropic=${!!process.env.ANTHROPIC_API_KEY}, OpenAI=${!!process.env.OPENAI_API_KEY}`);
1259
+ // Review finding: this hardcoded anthropic/openai only, so the boot
1260
+ // log silently never mentioned deepseek/openrouter once they existed.
1261
+ // Built from providers.names() now, same fix as /stats above.
1262
+ console.log(`📡 Providers: ${providers.names().map((name) => `${name}=${!!process.env[providers.envKey(name)]}`).join(', ')}`);
1245
1263
  console.log(`💾 Redis cache: ${cache.isConnected() ? 'connected' : 'disabled'}`);
1246
1264
  console.log(`🗄️ Metrics storage: ${metrics.usingPostgres() ? 'Postgres' : 'local JSONL'}`);
1247
1265
  runScheduledPrune();
@@ -1249,4 +1267,4 @@ if (require.main === module) {
1249
1267
  });
1250
1268
  }
1251
1269
 
1252
- module.exports = { app, isAuthConfigured, resolveEnvPathFromArgv, resolveTrustProxy, configure };
1270
+ module.exports = { app, isAuthConfigured, resolveEnvPathFromArgv, resolveTrustProxy, configure, ipv6SafeKeyGenerator };
package/streaming.js CHANGED
@@ -17,7 +17,7 @@
17
17
  // cache_type) beyond real OpenAI's wire format - the same deviation the
18
18
  // non-streaming JSON response already makes. This proxy is
19
19
  // OpenAI-COMPATIBLE in request/response SHAPE, not a byte-for-byte
20
- // clone of OpenAI's actual API; MemoCode's own callers need the cost
20
+ // clone of OpenAI's actual API; this proxy's own callers need the cost
21
21
  // data, and no spec-compliant client chokes on unknown extra JSON
22
22
  // fields it doesn't look for.
23
23