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/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 };