cachegate 1.2.0 → 1.3.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/LICENSE +21 -21
- package/failover.js +76 -76
- package/metrics.js +52 -2
- package/package.json +1 -1
- package/providers/anthropic.js +122 -122
- package/providers/openai.js +114 -114
- package/public/dashboard.html +1119 -1119
- package/server.js +19 -0
- package/streaming.js +77 -77
package/server.js
CHANGED
|
@@ -35,6 +35,25 @@ const openaiProvider = require('./providers/openai');
|
|
|
35
35
|
const failover = require('./failover');
|
|
36
36
|
|
|
37
37
|
const app = express();
|
|
38
|
+
// Any deployment behind a reverse proxy or load balancer (nginx,
|
|
39
|
+
// Traefik, Render, Heroku, ...) forwards the real client IP in
|
|
40
|
+
// X-Forwarded-For rather than as the raw socket address. Express's own
|
|
41
|
+
// default (`trust proxy` unset, i.e. false) makes express-rate-limit
|
|
42
|
+
// refuse that header outright the moment it's present - it throws
|
|
43
|
+
// ERR_ERL_UNEXPECTED_X_FORWARDED_FOR inside its key generator on every
|
|
44
|
+
// request through a rate-limited route, rather than risk keying
|
|
45
|
+
// per-caller limits off a spoofable header it hasn't been told to
|
|
46
|
+
// trust. Found running this behind a single-hop proxy in production
|
|
47
|
+
// (Cachegate Cloud, 2026-09-04) - not fatal to the request itself, but
|
|
48
|
+
// it means the per-IP rate limiter was keying off the proxy's own IP
|
|
49
|
+
// for every caller instead of each real client, so brute-force/abuse
|
|
50
|
+
// limiting on auth-style routes was effectively shared across ALL
|
|
51
|
+
// users rather than per-user. `1` (trust exactly one hop) is the
|
|
52
|
+
// correct value for a single reverse-proxy topology - the common case
|
|
53
|
+
// this engine actually runs behind. A deployment with more than one
|
|
54
|
+
// proxy hop in front of it should set this to the real hop count
|
|
55
|
+
// instead (see Express's own `trust proxy` docs) rather than assume 1.
|
|
56
|
+
app.set('trust proxy', 1);
|
|
38
57
|
// No X-Powered-By: Express - free, standard hardening (avoids handing a
|
|
39
58
|
// public-facing service's framework fingerprint to every caller for no
|
|
40
59
|
// benefit).
|
package/streaming.js
CHANGED
|
@@ -1,77 +1,77 @@
|
|
|
1
|
-
// model-router/streaming.js
|
|
2
|
-
//
|
|
3
|
-
// OpenAI-compatible SSE chunk framing, shared by both providers so
|
|
4
|
-
// server.js has exactly one wire format to write regardless of which
|
|
5
|
-
// provider actually answered - the provider adapters (chatStream()) do
|
|
6
|
-
// their own event-format translation and hand server.js plain text
|
|
7
|
-
// deltas plus a final usage/cost summary; this file turns that into the
|
|
8
|
-
// bytes that go on the wire.
|
|
9
|
-
//
|
|
10
|
-
// Scope for this increment: PLAIN TEXT CONTENT ONLY. Tool-call
|
|
11
|
-
// streaming (accumulating partial JSON arguments across chunks, one or
|
|
12
|
-
// more calls in flight at once) is a genuinely harder, separate
|
|
13
|
-
// problem - server.js rejects stream:true + tools with a clear error
|
|
14
|
-
// rather than attempt a half-working version of it.
|
|
15
|
-
//
|
|
16
|
-
// The final chunk carries extra fields (cost_usd, provider, cached,
|
|
17
|
-
// cache_type) beyond real OpenAI's wire format - the same deviation the
|
|
18
|
-
// non-streaming JSON response already makes. This proxy is
|
|
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
|
|
21
|
-
// data, and no spec-compliant client chokes on unknown extra JSON
|
|
22
|
-
// fields it doesn't look for.
|
|
23
|
-
|
|
24
|
-
const crypto = require('crypto');
|
|
25
|
-
|
|
26
|
-
function chunkFrame(payload) {
|
|
27
|
-
return `data: ${JSON.stringify(payload)}\n\n`;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function doneFrame() {
|
|
31
|
-
return 'data: [DONE]\n\n';
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function genId() {
|
|
35
|
-
return 'chatcmpl-' + crypto.randomBytes(12).toString('hex');
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function baseChunk(id, model, choice) {
|
|
39
|
-
return {
|
|
40
|
-
id,
|
|
41
|
-
object: 'chat.completion.chunk',
|
|
42
|
-
created: Math.floor(Date.now() / 1000),
|
|
43
|
-
model,
|
|
44
|
-
choices: [choice]
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function roleChunk({ id, model }) {
|
|
49
|
-
return chunkFrame(baseChunk(id, model, { index: 0, delta: { role: 'assistant' }, finish_reason: null }));
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function deltaChunk({ id, model, content }) {
|
|
53
|
-
return chunkFrame(baseChunk(id, model, { index: 0, delta: { content }, finish_reason: null }));
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function finalChunk({ id, model, usage, cost_usd, provider, cached, cache_type }) {
|
|
57
|
-
const frame = baseChunk(id, model, { index: 0, delta: {}, finish_reason: 'stop' });
|
|
58
|
-
frame.usage = usage;
|
|
59
|
-
frame.cost_usd = cost_usd;
|
|
60
|
-
frame.provider = provider;
|
|
61
|
-
frame.cached = !!cached;
|
|
62
|
-
if (cache_type) frame.cache_type = cache_type;
|
|
63
|
-
return chunkFrame(frame);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function errorFrame(message) {
|
|
67
|
-
return chunkFrame({ error: { message } });
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function startSse(res) {
|
|
71
|
-
res.setHeader('Content-Type', 'text/event-stream');
|
|
72
|
-
res.setHeader('Cache-Control', 'no-cache');
|
|
73
|
-
res.setHeader('Connection', 'keep-alive');
|
|
74
|
-
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
module.exports = { chunkFrame, doneFrame, genId, roleChunk, deltaChunk, finalChunk, errorFrame, startSse };
|
|
1
|
+
// model-router/streaming.js
|
|
2
|
+
//
|
|
3
|
+
// OpenAI-compatible SSE chunk framing, shared by both providers so
|
|
4
|
+
// server.js has exactly one wire format to write regardless of which
|
|
5
|
+
// provider actually answered - the provider adapters (chatStream()) do
|
|
6
|
+
// their own event-format translation and hand server.js plain text
|
|
7
|
+
// deltas plus a final usage/cost summary; this file turns that into the
|
|
8
|
+
// bytes that go on the wire.
|
|
9
|
+
//
|
|
10
|
+
// Scope for this increment: PLAIN TEXT CONTENT ONLY. Tool-call
|
|
11
|
+
// streaming (accumulating partial JSON arguments across chunks, one or
|
|
12
|
+
// more calls in flight at once) is a genuinely harder, separate
|
|
13
|
+
// problem - server.js rejects stream:true + tools with a clear error
|
|
14
|
+
// rather than attempt a half-working version of it.
|
|
15
|
+
//
|
|
16
|
+
// The final chunk carries extra fields (cost_usd, provider, cached,
|
|
17
|
+
// cache_type) beyond real OpenAI's wire format - the same deviation the
|
|
18
|
+
// non-streaming JSON response already makes. This proxy is
|
|
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
|
|
21
|
+
// data, and no spec-compliant client chokes on unknown extra JSON
|
|
22
|
+
// fields it doesn't look for.
|
|
23
|
+
|
|
24
|
+
const crypto = require('crypto');
|
|
25
|
+
|
|
26
|
+
function chunkFrame(payload) {
|
|
27
|
+
return `data: ${JSON.stringify(payload)}\n\n`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function doneFrame() {
|
|
31
|
+
return 'data: [DONE]\n\n';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function genId() {
|
|
35
|
+
return 'chatcmpl-' + crypto.randomBytes(12).toString('hex');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function baseChunk(id, model, choice) {
|
|
39
|
+
return {
|
|
40
|
+
id,
|
|
41
|
+
object: 'chat.completion.chunk',
|
|
42
|
+
created: Math.floor(Date.now() / 1000),
|
|
43
|
+
model,
|
|
44
|
+
choices: [choice]
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function roleChunk({ id, model }) {
|
|
49
|
+
return chunkFrame(baseChunk(id, model, { index: 0, delta: { role: 'assistant' }, finish_reason: null }));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function deltaChunk({ id, model, content }) {
|
|
53
|
+
return chunkFrame(baseChunk(id, model, { index: 0, delta: { content }, finish_reason: null }));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function finalChunk({ id, model, usage, cost_usd, provider, cached, cache_type }) {
|
|
57
|
+
const frame = baseChunk(id, model, { index: 0, delta: {}, finish_reason: 'stop' });
|
|
58
|
+
frame.usage = usage;
|
|
59
|
+
frame.cost_usd = cost_usd;
|
|
60
|
+
frame.provider = provider;
|
|
61
|
+
frame.cached = !!cached;
|
|
62
|
+
if (cache_type) frame.cache_type = cache_type;
|
|
63
|
+
return chunkFrame(frame);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function errorFrame(message) {
|
|
67
|
+
return chunkFrame({ error: { message } });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function startSse(res) {
|
|
71
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
72
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
73
|
+
res.setHeader('Connection', 'keep-alive');
|
|
74
|
+
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { chunkFrame, doneFrame, genId, roleChunk, deltaChunk, finalChunk, errorFrame, startSse };
|