@swfte/nexus-sdk 0.1.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.
@@ -0,0 +1,166 @@
1
+ 'use strict';
2
+ /**
3
+ * Epistemic classification — the thing the upstream vocabulary has no concept of.
4
+ *
5
+ * An OTel span has nowhere to put the distinction between *what the system did*, *what the model
6
+ * said it did*, and *what the user was told*. Every attribute in the GenAI conventions is agnostic
7
+ * on that question, because observability does not need to answer it and governance cannot proceed
8
+ * without it.
9
+ *
10
+ * The mapping is small enough to state in full:
11
+ *
12
+ * - **model output → `interaction_narrative`.** A completion is what the user was told. It may be
13
+ * true. It is not evidence that anything happened.
14
+ * - **observed tool execution → `behavior_trace`.** A tool span is the runtime reporting an effect
15
+ * it carried out. That is fact, and the only class admissible as evidence.
16
+ * - **model-authored reasoning → `rationalisation`.** A model's account of its own process is the
17
+ * weakest class of all, and the one most often mistaken for the strongest.
18
+ *
19
+ * Token accounting is `behavior_trace`: the number of tokens billed is measured, not asserted. It
20
+ * is emitted by `contract.tokenUsage`, which hard-codes the class, so it cannot drift from here.
21
+ *
22
+ * ── The invariant this module exists to hold ─────────────────────────────────────────────────
23
+ *
24
+ * **An unclassified span must never reach the ledger.** Two failure modes are being ruled out at
25
+ * once, and they call for opposite handling:
26
+ *
27
+ * - *Silently mislabelling* — defaulting an unknown span to `behavior_trace` would put unverified
28
+ * content into the class the product sells as evidence. Never do this.
29
+ * - *Silently dropping* — dropping without counting turns a coverage gap into an invisible one.
30
+ *
31
+ * So the runtime behaviour is **drop and count**, and the test behaviour is **throw**. `strict`
32
+ * defaults to on under `node --test` (and under `NEXUS_BRIDGE_STRICT=1`), so a new span kind fails
33
+ * a build rather than quietly thinning the ledger in production six months later.
34
+ */
35
+
36
+ const {
37
+ KIND_AGENT, KIND_CHAIN, KIND_EMBEDDING, KIND_EVALUATOR, KIND_GUARDRAIL, KIND_LLM,
38
+ KIND_RERANKER, KIND_RETRIEVER, KIND_TOOL, KNOWN_KINDS,
39
+ } = require('./semconv.cjs');
40
+
41
+ const EPISTEMIC_BEHAVIOR = 'behavior_trace';
42
+ const EPISTEMIC_NARRATIVE = 'interaction_narrative';
43
+ const EPISTEMIC_RATIONALISATION = 'rationalisation';
44
+
45
+ /** The classes the ledger accepts. Anything else is a bug, not a new category. */
46
+ const VALID_CLASSES = new Set([EPISTEMIC_BEHAVIOR, EPISTEMIC_NARRATIVE, EPISTEMIC_RATIONALISATION]);
47
+
48
+ /**
49
+ * Canonical span kind → the class of the *span itself*. Per-payload classes are decided by
50
+ * {@link classifyPayload}, because one LLM span legitimately produces records of two classes: its
51
+ * usage (measured, behaviour) and its completion (asserted, narrative).
52
+ */
53
+ const SPAN_CLASS = {
54
+ [KIND_LLM]: EPISTEMIC_NARRATIVE,
55
+ [KIND_TOOL]: EPISTEMIC_BEHAVIOR,
56
+ [KIND_RETRIEVER]: EPISTEMIC_BEHAVIOR,
57
+ [KIND_EMBEDDING]: EPISTEMIC_BEHAVIOR,
58
+ [KIND_RERANKER]: EPISTEMIC_BEHAVIOR,
59
+ [KIND_GUARDRAIL]: EPISTEMIC_BEHAVIOR,
60
+ // A chain/agent span is the framework's own record of orchestration it performed — an observed
61
+ // execution, not a claim about one.
62
+ [KIND_CHAIN]: EPISTEMIC_BEHAVIOR,
63
+ [KIND_AGENT]: EPISTEMIC_BEHAVIOR,
64
+ // An evaluator is a model judging output. Its verdict is produced text about a process, which is
65
+ // the definition of a rationalisation however confident the score looks.
66
+ [KIND_EVALUATOR]: EPISTEMIC_RATIONALISATION,
67
+ };
68
+
69
+ // Payload kinds the bridge can emit.
70
+ const PAYLOAD_USAGE = 'usage';
71
+ const PAYLOAD_OUTPUT = 'output';
72
+ const PAYLOAD_REASONING = 'reasoning';
73
+ const PAYLOAD_TOOL = 'tool';
74
+
75
+ const PAYLOAD_CLASS = {
76
+ [PAYLOAD_USAGE]: EPISTEMIC_BEHAVIOR, // measured, from the provider's own accounting
77
+ [PAYLOAD_OUTPUT]: EPISTEMIC_NARRATIVE, // what the user was told
78
+ [PAYLOAD_REASONING]: EPISTEMIC_RATIONALISATION, // what the model said about its own process
79
+ [PAYLOAD_TOOL]: EPISTEMIC_BEHAVIOR, // an effect the runtime observed
80
+ };
81
+
82
+ /**
83
+ * Thrown in strict mode when a GenAI span cannot be assigned an epistemic class.
84
+ *
85
+ * Never escapes the bridge in production: the bridge entry points are guarded and strict is off.
86
+ * It exists so the classification gap is a red test rather than a quiet decrease in a dashboard.
87
+ */
88
+ class UnclassifiedSpan extends Error {
89
+ constructor(message) {
90
+ super(message);
91
+ this.name = 'UnclassifiedSpan';
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Strict under test, lenient in production — the two places want opposite failure modes.
97
+ *
98
+ * Python detects pytest via `sys.modules`. The Node equivalent is the test runner's own environment
99
+ * variable, which `node --test` sets in every worker it spawns. Falling back to `NODE_ENV=test`
100
+ * covers a customer running the suite under a different harness.
101
+ */
102
+ function strictDefault() {
103
+ const flag = String(process.env.NEXUS_BRIDGE_STRICT || '').trim().toLowerCase();
104
+ if (['1', 'true', 'yes', 'on'].includes(flag)) return true;
105
+ if (['0', 'false', 'no', 'off'].includes(flag)) return false;
106
+ return Boolean(process.env.NODE_TEST_CONTEXT) || process.env.NODE_ENV === 'test';
107
+ }
108
+
109
+ /**
110
+ * The epistemic class of a span, or `null` when it has none.
111
+ *
112
+ * `null` means two different things and the caller must treat them differently, which is why
113
+ * {@link isOurs} exists alongside this:
114
+ *
115
+ * - a span that is not GenAI at all (an HTTP call, a DB query) — not ours, ignore in silence;
116
+ * - a GenAI span whose kind we do not recognise — ours, and a coverage failure. Counted, dropped,
117
+ * and in strict mode thrown.
118
+ */
119
+ function classifySpan(facts, strict) {
120
+ if (!facts || !facts.isGenai) return null;
121
+ const cls = SPAN_CLASS[facts.kind];
122
+ if (cls === undefined) {
123
+ const s = strict === undefined || strict === null ? strictDefault() : strict;
124
+ if (s) {
125
+ throw new UnclassifiedSpan(
126
+ 'span ' + JSON.stringify(facts.name) + ' (kind=' + JSON.stringify(facts.kind) +
127
+ ', vocabulary=' + JSON.stringify(facts.vocabulary) + ', scope=' +
128
+ JSON.stringify(facts.scope) + ') carries GenAI attributes but no epistemic class is ' +
129
+ 'defined for it. Add it to SPAN_CLASS — do NOT default it.');
130
+ }
131
+ return null;
132
+ }
133
+ if (!VALID_CLASSES.has(cls)) { // unreachable; a typo in the table must not reach the wire
134
+ throw new UnclassifiedSpan(JSON.stringify(cls) + ' is not an epistemic class');
135
+ }
136
+ return cls;
137
+ }
138
+
139
+ /**
140
+ * The class of one emitted record. Throws on an unknown payload kind — a caller inventing a payload
141
+ * without deciding its class is the exact mislabelling this module forbids.
142
+ */
143
+ function classifyPayload(payloadKind) {
144
+ const cls = PAYLOAD_CLASS[payloadKind];
145
+ if (cls === undefined) {
146
+ throw new UnclassifiedSpan('no epistemic class defined for payload ' + JSON.stringify(payloadKind));
147
+ }
148
+ return cls;
149
+ }
150
+
151
+ /** True when this span belongs to the GenAI vocabulary at all — recognised kind or not. */
152
+ function isOurs(facts) {
153
+ return Boolean(facts && facts.isGenai);
154
+ }
155
+
156
+ /** A GenAI span of a kind we do not model. The number worth alerting on. */
157
+ function isCoverageGap(facts) {
158
+ return Boolean(facts && facts.isGenai && !KNOWN_KINDS.has(facts.kind));
159
+ }
160
+
161
+ module.exports = {
162
+ EPISTEMIC_BEHAVIOR, EPISTEMIC_NARRATIVE, EPISTEMIC_RATIONALISATION, VALID_CLASSES,
163
+ SPAN_CLASS, PAYLOAD_CLASS,
164
+ PAYLOAD_USAGE, PAYLOAD_OUTPUT, PAYLOAD_REASONING, PAYLOAD_TOOL,
165
+ UnclassifiedSpan, strictDefault, classifySpan, classifyPayload, isOurs, isCoverageGap,
166
+ };
@@ -0,0 +1,84 @@
1
+ 'use strict';
2
+ /**
3
+ * `@swfte/nexus-sdk/otel` — the OpenTelemetry span bridge.
4
+ *
5
+ * import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
6
+ * import * as nexus from '@swfte/nexus-sdk';
7
+ * import { spanProcessor } from '@swfte/nexus-sdk/otel';
8
+ *
9
+ * nexus.init({ service: 'checkout', env: 'prod' });
10
+ * const provider = new NodeTracerProvider({ spanProcessors: [spanProcessor()] });
11
+ * provider.register();
12
+ *
13
+ * Note the direction, which is the same one the AI SDK bridge uses: **the customer imports
14
+ * OpenTelemetry, not us.** This package has no dependency on any `@opentelemetry/*` package, no
15
+ * peer dependency, and no version range to conflict with theirs — it hands back a plain object with
16
+ * `onStart`/`onEnd`/`shutdown`/`forceFlush`, which is the whole of the `SpanProcessor` interface,
17
+ * and their provider calls it.
18
+ *
19
+ * That also means this file cannot break when OpenTelemetry changes its exports. It can only be
20
+ * wrong about an *attribute name*, which is what `semconv.cjs`'s tables and the tests around them
21
+ * exist to pin.
22
+ *
23
+ * ── What this bridge will and will not put on the wire ───────────────────────────────────────
24
+ *
25
+ * - `token_usage` with exact cost including the cache split, from whatever GenAI vocabulary the
26
+ * customer's instrumentation speaks (OTel semconv, OpenInference, OpenLLMetry/OpenLIT).
27
+ * - `tool_action` for tool spans, whose `target` is an identifier or nothing, and whose arguments
28
+ * contribute a content-free *shape* rather than their values.
29
+ * - `model_response` and `model_thinking` for model-authored text, re-gated by the customer's own
30
+ * privacy tier because upstream redaction is not trusted, and split by epistemic class because a
31
+ * completion and a reasoning trace are different kinds of claim.
32
+ * - **No `prompt` event.** The prompt is read and emitted nowhere, matching the Python SDK. There
33
+ * is no `prompt` builder in the devtools event set for a second producer to emit against.
34
+ * - **Nothing at all for a GenAI span whose kind is not modelled.** Dropped and counted, never
35
+ * defaulted into `behavior_trace` — see `classify.cjs`, which is the point of the module.
36
+ */
37
+
38
+ const core = require('../core.cjs');
39
+ const { Bridge, SEMCONV_TAG } = require('./bridge.cjs');
40
+ const semconv = require('./semconv.cjs');
41
+ const classify = require('./classify.cjs');
42
+
43
+ let _bridge = null;
44
+
45
+ /** The process-wide bridge. One per process, because it is stateless apart from its counters. */
46
+ function bridge(opts) {
47
+ if (_bridge === null || (opts && opts.fresh)) _bridge = new Bridge(core, opts);
48
+ return _bridge;
49
+ }
50
+
51
+ /**
52
+ * A `SpanProcessor` for an OpenTelemetry `TracerProvider`.
53
+ *
54
+ * Duck-typed rather than subclassed, for the reason in the module header. All four methods are
55
+ * required by the interface; only `onEnd` does anything, because a span's attributes are not
56
+ * complete until it closes and reading them at `onStart` would report a call that had not happened.
57
+ */
58
+ function spanProcessor(opts) {
59
+ const b = bridge(opts);
60
+ return {
61
+ onStart() { /* attributes are not complete until the span ends */ },
62
+ onEnd(span) { b.ingest(span); },
63
+ // Nothing is buffered here: `ingest` hands events straight to the client's own queue, which has
64
+ // its own flush and its own deadline. Returning a resolved promise rather than draining is
65
+ // therefore honest — there is nothing of ours left to drain.
66
+ forceFlush() { return Promise.resolve(); },
67
+ shutdown() { return Promise.resolve(); },
68
+ };
69
+ }
70
+
71
+ /** Read one span and emit its events directly, without a provider. Returns whether it emitted. */
72
+ function ingest(span) {
73
+ return bridge().ingest(span);
74
+ }
75
+
76
+ /** What the bridge has seen: `{seen, emitted, ignored, unclassified}`. */
77
+ function stats() {
78
+ return bridge().stats();
79
+ }
80
+
81
+ module.exports = {
82
+ spanProcessor, ingest, stats, bridge,
83
+ Bridge, SEMCONV_TAG, semconv, classify,
84
+ };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * `@swfte/nexus-sdk/otel` — ESM entry for the OpenTelemetry span bridge.
3
+ *
4
+ * import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
5
+ * import * as nexus from '@swfte/nexus-sdk';
6
+ * import { spanProcessor } from '@swfte/nexus-sdk/otel';
7
+ *
8
+ * nexus.init({ service: 'checkout', env: 'prod' });
9
+ * new NodeTracerProvider({ spanProcessors: [spanProcessor()] }).register();
10
+ *
11
+ * A facade over `./index.cjs`, for the same reason `src/index.js` and `src/ai.js` are facades: the
12
+ * state has to live in exactly one module instance, or a process reaching this package through both
13
+ * `import` and `require` gets two clients and two session ids. Written out as named re-exports
14
+ * rather than relying on CJS interop, so the named bindings are guaranteed rather than dependent on
15
+ * how well the module lexer reads the CommonJS file.
16
+ */
17
+ import bridgeModule from './index.cjs';
18
+
19
+ /** A `SpanProcessor` for an OpenTelemetry `TracerProvider`. Duck-typed; nothing is imported from
20
+ * `@opentelemetry/*`, so there is no dependency and no version range to conflict with yours. */
21
+ export const spanProcessor = bridgeModule.spanProcessor;
22
+
23
+ /** Read one finished span and emit its events directly, without a provider. */
24
+ export const ingest = bridgeModule.ingest;
25
+
26
+ /** What the bridge has seen: `{seen, emitted, ignored, unclassified}`. */
27
+ export const stats = bridgeModule.stats;
28
+
29
+ /** The process-wide bridge instance. Mostly for tests. */
30
+ export const bridge = bridgeModule.bridge;
31
+
32
+ /** Which semconv release the reader was written against. */
33
+ export const SEMCONV_TAG = bridgeModule.SEMCONV_TAG;
34
+
35
+ /** The span reader. Exported so a customer can inspect what a span reduces to before trusting it. */
36
+ export const semconv = bridgeModule.semconv;
37
+
38
+ /** The epistemic classifier, including `UnclassifiedSpan` and the strict-mode switch. */
39
+ export const classify = bridgeModule.classify;