cachegate 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +83 -4
- package/LICENSE +21 -21
- package/README.md +154 -15
- package/cache.js +133 -9
- package/cascade.js +206 -0
- package/coalescing.js +65 -0
- package/embeddings.js +78 -16
- package/failover.js +76 -76
- package/guardrails.js +80 -0
- package/metrics.js +93 -7
- package/package.json +12 -2
- package/pii.js +128 -0
- package/providers/anthropic.js +122 -122
- package/providers/deepseek.js +194 -0
- package/providers/index.js +62 -0
- package/providers/openai.js +122 -114
- package/providers/openrouter.js +233 -0
- package/public/dashboard.html +1120 -1119
- package/router.js +33 -4
- package/semanticCache.js +115 -3
- package/server.js +494 -99
- package/streaming.js +77 -77
- package/tracing.js +97 -0
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 };
|
package/tracing.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// model-router/tracing.js
|
|
2
|
+
//
|
|
3
|
+
// OpenTelemetry export (roadmap step 36.2). Builds on the request-scoped
|
|
4
|
+
// `trace_id` that server.js generates (step 36.1): that id is set as a span
|
|
5
|
+
// attribute so a trace in an OTel backend and a metrics.js row for the same
|
|
6
|
+
// request can be cross-referenced by the same value even though they are two
|
|
7
|
+
// different systems.
|
|
8
|
+
//
|
|
9
|
+
// Gated off by default (OTEL_ENABLED). The @opentelemetry/api no-op tracer
|
|
10
|
+
// is always present (it is the API surface and costs nothing when the SDK is
|
|
11
|
+
// not initialized), but the heavy SDK + exporter are lazily required and only
|
|
12
|
+
// started when BOTH OTEL_ENABLED=true AND OTEL_EXPORTER_OTLP_ENDPOINT is set -
|
|
13
|
+
// "never initializes at all" when unused, not "initializes and exports
|
|
14
|
+
// nowhere". Spans here are MANUAL ONLY: auto-instrumentation is explicitly out
|
|
15
|
+
// of scope (spec 220), so no registerInstrumentations() call.
|
|
16
|
+
|
|
17
|
+
const { trace, context } = require('@opentelemetry/api');
|
|
18
|
+
|
|
19
|
+
const TRACER_NAME = 'cachegate';
|
|
20
|
+
|
|
21
|
+
let initialized = false;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Initialize the OTel SDK exactly once. A no-op unless both gates are open.
|
|
25
|
+
* Any SDK failure is logged and swallowed (fail-open): tracing must never be
|
|
26
|
+
* the reason a real request fails.
|
|
27
|
+
*/
|
|
28
|
+
function initTracing() {
|
|
29
|
+
if (initialized) return;
|
|
30
|
+
initialized = true;
|
|
31
|
+
if (process.env.OTEL_ENABLED !== 'true') return;
|
|
32
|
+
const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
|
33
|
+
if (!endpoint) return;
|
|
34
|
+
try {
|
|
35
|
+
// Lazy-require the SDK + exporter only on the enabled path, so a
|
|
36
|
+
// deployment that never opts in never loads them.
|
|
37
|
+
const { NodeSDK } = require('@opentelemetry/sdk-node');
|
|
38
|
+
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
|
|
39
|
+
const sdk = new NodeSDK({
|
|
40
|
+
traceExporter: new OTLPTraceExporter({ url: endpoint })
|
|
41
|
+
// No instrumentations: manual spans only (spec 220, out of scope).
|
|
42
|
+
});
|
|
43
|
+
sdk.start();
|
|
44
|
+
process.on('SIGTERM', () => {
|
|
45
|
+
sdk.shutdown().finally(() => process.exit(0));
|
|
46
|
+
});
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.warn('⚠️ OTel SDK failed to initialize, tracing disabled:', err.message);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Start a span as a child of whatever span is currently active. With a no-op
|
|
54
|
+
* tracer (SDK off) this is a no-op object whose methods are all safe no-ops.
|
|
55
|
+
*/
|
|
56
|
+
function startSpan(name, attributes = {}) {
|
|
57
|
+
return trace.getTracer(TRACER_NAME).startSpan(name, { attributes });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Run `fn` inside a span named `name`, ending it when `fn` settles. The span
|
|
62
|
+
* is a child of the active span (the request's root span, set via
|
|
63
|
+
* withRootSpan). Does NOT make the new span itself active - none of the
|
|
64
|
+
* wrapped operations start their own nested spans, so there is nothing to
|
|
65
|
+
* nest. Re-throws on failure after recording the exception on the span.
|
|
66
|
+
*/
|
|
67
|
+
async function withSpan(name, attributes, fn) {
|
|
68
|
+
const span = startSpan(name, attributes);
|
|
69
|
+
try {
|
|
70
|
+
return await fn();
|
|
71
|
+
} catch (err) {
|
|
72
|
+
span.recordException(err);
|
|
73
|
+
throw err;
|
|
74
|
+
} finally {
|
|
75
|
+
span.end();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Run `fn` inside a NEW root span (the one span per request), making it the
|
|
81
|
+
* active context so every `withSpan`/`startSpan` inside nests under it.
|
|
82
|
+
* `trace_id` is carried as a span attribute so this trace can be joined to
|
|
83
|
+
* metrics rows for the same request.
|
|
84
|
+
*/
|
|
85
|
+
async function withRootSpan(name, attributes, fn) {
|
|
86
|
+
const span = startSpan(name, attributes);
|
|
87
|
+
try {
|
|
88
|
+
return await context.with(trace.setSpan(context.active(), span), fn);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
span.recordException(err);
|
|
91
|
+
throw err;
|
|
92
|
+
} finally {
|
|
93
|
+
span.end();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { initTracing, startSpan, withSpan, withRootSpan };
|