cachegate 1.4.0 → 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 +15 -0
- package/README.md +6 -15
- package/eval/semantic-cache-eval-pairs.json +28 -0
- package/eval/semantic-cache-eval.js +82 -0
- package/metrics.js +66 -21
- package/package.json +4 -2
- package/redisClient.js +6 -6
- package/router.js +20 -6
- package/server.js +42 -24
- package/streaming.js +1 -1
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
|
@@ -7,12 +7,6 @@ A self-hostable, OpenAI-compatible proxy that routes LLM requests to the
|
|
|
7
7
|
cheapest currently-healthy provider, caches responses both exactly and
|
|
8
8
|
semantically, and tracks cost and latency per call.
|
|
9
9
|
|
|
10
|
-
Would rather not run it yourself? [Cachegate Cloud](https://cachegate.memocodesystem.com)
|
|
11
|
-
is a hosted version of this same engine — BYOK, so you're never paying
|
|
12
|
-
us for tokens, only for the caching/routing layer on top. Everything
|
|
13
|
-
below in this README describes the engine itself; both this repo and
|
|
14
|
-
the hosted product share it.
|
|
15
|
-
|
|
16
10
|
## Why this instead of LiteLLM / Portkey / OpenRouter?
|
|
17
11
|
|
|
18
12
|
Those are all excellent, and this doesn't try to out-feature them (140+
|
|
@@ -41,10 +35,7 @@ fills instead:
|
|
|
41
35
|
|
|
42
36
|
- **Not a hosted service.** There's no cloud offering, no login, no
|
|
43
37
|
billing, no multi-tenant key custody here — this is the engine you
|
|
44
|
-
run yourself.
|
|
45
|
-
is a separate, closed product built on top of this same engine — not
|
|
46
|
-
a fork of this one, and not something this repository will ever grow
|
|
47
|
-
into. This project intentionally doesn't ship the pieces (billing,
|
|
38
|
+
run yourself. This project intentionally doesn't ship the pieces (billing,
|
|
48
39
|
multi-tenant key custody, a login system) that a competing hosted
|
|
49
40
|
offering would need, and isn't looking for PRs that add them (see
|
|
50
41
|
`CONTRIBUTING.md`'s scope note) — not because the license forbids it
|
|
@@ -192,8 +183,8 @@ the folder. Pick **standalone** if cachegate should be one shared
|
|
|
192
183
|
service serving multiple apps (or you'd rather manage it as its own
|
|
193
184
|
deployable thing). Pick **colocated** if you want everything - your app
|
|
194
185
|
plus its router - in one repo, one place to look, no second project to
|
|
195
|
-
maintain (this is exactly how this router lives inside
|
|
196
|
-
monorepo today).
|
|
186
|
+
maintain (this is exactly how this router lives inside at least one
|
|
187
|
+
production app's own monorepo today).
|
|
197
188
|
|
|
198
189
|
**Reaching it once it's running:**
|
|
199
190
|
- Same machine, calling app not containerized: `http://localhost:4000`.
|
|
@@ -625,9 +616,9 @@ positioning. A few things worth knowing before relying on it:
|
|
|
625
616
|
no database of its own, but ephemeral on most hosts (a restart/
|
|
626
617
|
redeploy wipes a container's own filesystem). Set `DATABASE_URL` to a
|
|
627
618
|
real Postgres connection string and every metrics function
|
|
628
|
-
transparently reads/writes there instead -
|
|
629
|
-
|
|
630
|
-
|
|
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.
|
|
631
622
|
Same public API either way (`record`/`readRecent`/`providerStats`/
|
|
632
623
|
`rangeSummary`/`pruneOlderThan`); nothing outside `metrics.js` needs
|
|
633
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
|
|
46
|
-
// has a real Postgres database
|
|
47
|
-
// user-account/library data regardless of this
|
|
48
|
-
// costs nothing new (no extra service, no extra
|
|
49
|
-
// and, unlike the router's own container
|
|
50
|
-
// a restart/redeploy. DATABASE_URL is
|
|
51
|
-
// for injecting a database's
|
|
52
|
-
//
|
|
53
|
-
// reason) - set it and every
|
|
54
|
-
// Postgres instead of local
|
|
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
|
-
|
|
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 (
|
|
83
|
-
// is a no-op on an already-created table - ADD
|
|
84
|
-
// what actually reaches an existing table's
|
|
85
|
-
//
|
|
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
|
|
500
|
-
// estimatedSavings / estimatedSavingsGlobal
|
|
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.
|
|
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
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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 -
|
|
107
|
-
// TRUST_PROXY=1 in render.yaml
|
|
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
|
|
149
|
-
// already carries
|
|
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
|
|
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 (
|
|
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
|
-
//
|
|
281
|
-
// exactly ONE caller identity -
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
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
|
|
301
|
-
//
|
|
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:
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
|