@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.
- package/LICENSE +201 -0
- package/NOTICE +38 -0
- package/README.md +414 -0
- package/ai.d.ts +84 -0
- package/index.d.ts +433 -0
- package/otel.d.ts +80 -0
- package/package.json +93 -0
- package/policy.d.ts +141 -0
- package/src/ai.cjs +334 -0
- package/src/ai.js +39 -0
- package/src/core.cjs +2411 -0
- package/src/health.cjs +172 -0
- package/src/index.cjs +53 -0
- package/src/index.js +151 -0
- package/src/otel/bridge.cjs +257 -0
- package/src/otel/classify.cjs +166 -0
- package/src/otel/index.cjs +84 -0
- package/src/otel/index.js +39 -0
- package/src/otel/semconv.cjs +650 -0
- package/src/policy/engine.cjs +368 -0
- package/src/policy/envelope.cjs +256 -0
- package/src/policy/index.cjs +224 -0
- package/src/policy/rules.cjs +442 -0
- package/src/pricing.cjs +188 -0
- package/src/provenance.cjs +304 -0
- package/src/redact.cjs +734 -0
|
@@ -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;
|