@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,650 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* One normalised view over three GenAI span vocabularies. A port of `nexus/otel/semconv.py`.
|
|
4
|
+
*
|
|
5
|
+
* We do not write provider adapters. One OpenLLMetry provider adapter was measured at 931 lines
|
|
6
|
+
* across 8 files, over ~30 packages — 15–25k lines of maintained, permissively licensed work that
|
|
7
|
+
* three funded projects already do. Re-implementing it would be the most expensive possible way to
|
|
8
|
+
* arrive at a worse version of somebody else's tested extraction logic.
|
|
9
|
+
*
|
|
10
|
+
* So this module is a *reader*, not an adapter. It takes a span produced by whichever
|
|
11
|
+
* instrumentation the customer already runs and reduces it to {@link SpanFacts} — the small set of
|
|
12
|
+
* things the nexus ledger actually needs. Three vocabularies are read:
|
|
13
|
+
*
|
|
14
|
+
* - **OpenTelemetry GenAI semantic conventions**, targeted at v1.42.0. That spec is still
|
|
15
|
+
* pre-stable and is a moving target: content capture moved from `gen_ai.prompt`/`gen_ai.completion`
|
|
16
|
+
* to `gen_ai.input.messages`/`gen_ai.output.messages`, and `gen_ai.system` became
|
|
17
|
+
* `gen_ai.provider.name`. Both spellings are read on purpose. Reading a superseded key costs one
|
|
18
|
+
* lookup; failing to read it costs a customer their token counts for however long it takes
|
|
19
|
+
* somebody to notice a number went quietly to zero.
|
|
20
|
+
* - **OpenInference** (Arize, Apache-2.0) — the structural base. `openinference.span.kind` is the
|
|
21
|
+
* only vocabulary of the three that states what a span *is* rather than leaving it to be inferred
|
|
22
|
+
* from the instrumentation's name, which is why classification keys off it first.
|
|
23
|
+
* - **OpenLLMetry** (Traceloop) / **OpenLIT** — breadth.
|
|
24
|
+
*
|
|
25
|
+
* Nothing here imports an OpenTelemetry package. A span is read *structurally* — `.attributes`,
|
|
26
|
+
* `.context`, `.parent` — so the bridge works against a real `ReadableSpan`, against an
|
|
27
|
+
* OpenInference span, and against a plain object in a test, with nothing installed.
|
|
28
|
+
*
|
|
29
|
+
* ── One rule about content, and the bug that made it a written rule ──────────────────────────
|
|
30
|
+
*
|
|
31
|
+
* Every attribute read here was written by instrumentation the customer installed, and much of it
|
|
32
|
+
* is the customer's own text and the model's own output. Three fields — `inputText`, `outputText`,
|
|
33
|
+
* `reasoningText` — are *declared* to be content: the bridge emits the last two only through the
|
|
34
|
+
* tier gate, and emits `inputText` nowhere at all. **Every other field this reader produces must be
|
|
35
|
+
* structure**: an identifier, a number, a type, a count.
|
|
36
|
+
*
|
|
37
|
+
* The rule is written down because it was broken, in exactly the shape found independently in this
|
|
38
|
+
* SDK's own `tool_action.effect`: `tool.parameters` (a tool call's arguments) and `input.value` (a
|
|
39
|
+
* span's raw input) were read into `toolTarget`, which the contract treats as an identifier. The
|
|
40
|
+
* fix is not a better gate. It is not putting a payload in front of one. See {@link identifier},
|
|
41
|
+
* which decides what may be a target, and {@link shapeOf}, which is what the arguments contribute
|
|
42
|
+
* instead.
|
|
43
|
+
*
|
|
44
|
+
* One caveat the rule does not cover: `SpanFacts.attributes` keeps the raw attribute mapping —
|
|
45
|
+
* prompts included — because the bridge re-reads counters from it. Nothing emits it and it never
|
|
46
|
+
* leaves the process, but it is why a heap dump of a host running this bridge contains prompts.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/** The semconv release this module was written against. Pinned: a pre-stable convention that
|
|
50
|
+
* silently drifts is worse than one that fails a test. */
|
|
51
|
+
const GENAI_SEMCONV_VERSION = '1.42.0';
|
|
52
|
+
const GENAI_SEMCONV_STABILITY = 'pre-stable';
|
|
53
|
+
|
|
54
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
// Attribute keys, grouped by the concept rather than by the vocabulary that spells it.
|
|
56
|
+
// First hit wins, so the order within each list is most-authoritative → most-legacy.
|
|
57
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
const KIND_KEYS = ['openinference.span.kind', 'traceloop.span.kind', 'gen_ai.operation.name'];
|
|
60
|
+
|
|
61
|
+
const PROVIDER_KEYS = [
|
|
62
|
+
'gen_ai.provider.name', // semconv >= 1.36
|
|
63
|
+
'gen_ai.system', // semconv < 1.36, still emitted by most shipped instrumentations
|
|
64
|
+
'llm.provider', // OpenInference
|
|
65
|
+
'llm.system',
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
const REQUEST_MODEL_KEYS = ['gen_ai.request.model', 'llm.model_name', 'llm.request.model', 'gen_ai.model'];
|
|
69
|
+
const RESPONSE_MODEL_KEYS = ['gen_ai.response.model', 'llm.response.model', 'llm.model_name'];
|
|
70
|
+
|
|
71
|
+
const INPUT_TOKEN_KEYS = [
|
|
72
|
+
'gen_ai.usage.input_tokens',
|
|
73
|
+
'gen_ai.usage.prompt_tokens', // OpenLLMetry / pre-1.27 semconv
|
|
74
|
+
'llm.token_count.prompt', // OpenInference
|
|
75
|
+
];
|
|
76
|
+
const OUTPUT_TOKEN_KEYS = [
|
|
77
|
+
'gen_ai.usage.output_tokens',
|
|
78
|
+
'gen_ai.usage.completion_tokens',
|
|
79
|
+
'llm.token_count.completion',
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
// Cache accounting. This is the field set that makes "exact cost" true rather than approximately
|
|
83
|
+
// true: cached input is an order of magnitude cheaper than fresh input, so a bridge that folds
|
|
84
|
+
// cache reads into `input_tokens` overstates a cache-heavy workload's bill by multiples.
|
|
85
|
+
const CACHE_READ_KEYS = [
|
|
86
|
+
'gen_ai.usage.cache_read_input_tokens',
|
|
87
|
+
'gen_ai.usage.cached_input_tokens',
|
|
88
|
+
'llm.token_count.prompt_details.cache_read',
|
|
89
|
+
'gen_ai.usage.cache_read_tokens',
|
|
90
|
+
];
|
|
91
|
+
const CACHE_WRITE_5M_KEYS = [
|
|
92
|
+
'gen_ai.usage.cache_creation.ephemeral_5m_input_tokens',
|
|
93
|
+
'gen_ai.usage.cache_creation_input_tokens',
|
|
94
|
+
'llm.token_count.prompt_details.cache_write',
|
|
95
|
+
'gen_ai.usage.cache_write_tokens',
|
|
96
|
+
];
|
|
97
|
+
const CACHE_WRITE_1H_KEYS = [
|
|
98
|
+
'gen_ai.usage.cache_creation.ephemeral_1h_input_tokens',
|
|
99
|
+
// Flattened spelling. OTel attribute keys are a flat namespace, so instrumentations routinely
|
|
100
|
+
// collapse Anthropic's nested `cache_creation.ephemeral_1h_input_tokens` rather than emit a
|
|
101
|
+
// dotted sub-object. Reading both costs a lookup; reading only one prices a 1-hour write at the
|
|
102
|
+
// 5-minute rate — a 1.6x understatement that looks like a plausible number.
|
|
103
|
+
'gen_ai.usage.cache_creation_1h_tokens',
|
|
104
|
+
'llm.token_count.prompt_details.cache_write_1h',
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
/** A cost the *provider* reported. Preferred over our rate-card arithmetic: a number from the
|
|
108
|
+
* vendor is evidence, a number from a rate card is an inference. */
|
|
109
|
+
const REPORTED_COST_KEYS = ['gen_ai.usage.cost', 'llm.cost.total', 'gen_ai.usage.total_cost'];
|
|
110
|
+
|
|
111
|
+
/** Explicit retry/attempt counters, where the instrumentation bothers to emit one. */
|
|
112
|
+
const ATTEMPT_KEYS = ['gen_ai.request.attempt', 'llm.retry.count', 'http.request.resend_count',
|
|
113
|
+
'retry.count'];
|
|
114
|
+
|
|
115
|
+
const OUTPUT_TEXT_KEYS = [
|
|
116
|
+
'gen_ai.output.messages', // semconv 1.42 content capture (opt-in upstream)
|
|
117
|
+
'gen_ai.completion', // legacy semconv / OpenLLMetry
|
|
118
|
+
'gen_ai.completion.0.content',
|
|
119
|
+
'output.value', // OpenInference
|
|
120
|
+
'llm.output_messages.0.message.content',
|
|
121
|
+
];
|
|
122
|
+
const INPUT_TEXT_KEYS = [
|
|
123
|
+
'gen_ai.input.messages',
|
|
124
|
+
'gen_ai.prompt',
|
|
125
|
+
'gen_ai.prompt.0.content',
|
|
126
|
+
'input.value',
|
|
127
|
+
'llm.input_messages.0.message.content',
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
/** Model-authored reasoning. Distinct from output text because it is a *different epistemic class*:
|
|
131
|
+
* a model's account of its own process is `rationalisation`, never `behavior_trace`. Folding it
|
|
132
|
+
* into the answer would launder a claim into the record as if it were an observation. */
|
|
133
|
+
const REASONING_KEYS = [
|
|
134
|
+
'gen_ai.output.reasoning',
|
|
135
|
+
'gen_ai.completion.reasoning',
|
|
136
|
+
'llm.output_messages.0.message.reasoning',
|
|
137
|
+
'llm.reasoning',
|
|
138
|
+
'traceloop.entity.thinking',
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
const TOOL_NAME_KEYS = ['gen_ai.tool.name', 'tool.name', 'traceloop.entity.name'];
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Keys that name *which* tool call this is. A target is an identifier — a call id, a tool id, an
|
|
145
|
+
* endpoint — and nothing else. `tool.parameters` and `input.value` used to be read here and are not
|
|
146
|
+
* identifiers: the first is the arguments the model chose, the second is OpenInference's verbatim
|
|
147
|
+
* span input. Both are content, both landed in `tool_action.target`, and the only thing between
|
|
148
|
+
* that field and the collector was a text gate. Content must not be standing in front of a gate in
|
|
149
|
+
* the first place — see {@link TOOL_ARG_KEYS}.
|
|
150
|
+
*/
|
|
151
|
+
const TOOL_TARGET_KEYS = ['gen_ai.tool.call.id', 'tool.id'];
|
|
152
|
+
|
|
153
|
+
/** Keys whose value is the *payload* of a call. Never read as text, only ever reduced by
|
|
154
|
+
* {@link shapeOf}. Named and handled rather than silently unread: the ledger still wants to know
|
|
155
|
+
* a tool was called with three arguments named `city`, `units` and `when`, and that fact does not
|
|
156
|
+
* require their values. */
|
|
157
|
+
const TOOL_ARG_KEYS = ['gen_ai.tool.call.arguments', 'tool.parameters', 'tool.arguments',
|
|
158
|
+
'input.value', 'traceloop.entity.input'];
|
|
159
|
+
|
|
160
|
+
const AGENT_NAME_KEYS = ['gen_ai.agent.name', 'agent.name'];
|
|
161
|
+
|
|
162
|
+
const HTTP_MARKERS = ['http.request.method', 'http.method', 'url.full', 'http.url', 'server.address'];
|
|
163
|
+
|
|
164
|
+
/** Canonical span kinds. The bridge classifies against *these*, not against any one vocabulary, so
|
|
165
|
+
* adding a fourth convention later is a table edit rather than a classifier rewrite. */
|
|
166
|
+
const KIND_LLM = 'llm';
|
|
167
|
+
const KIND_TOOL = 'tool';
|
|
168
|
+
const KIND_CHAIN = 'chain';
|
|
169
|
+
const KIND_AGENT = 'agent';
|
|
170
|
+
const KIND_RETRIEVER = 'retriever';
|
|
171
|
+
const KIND_EMBEDDING = 'embedding';
|
|
172
|
+
const KIND_RERANKER = 'reranker';
|
|
173
|
+
const KIND_GUARDRAIL = 'guardrail';
|
|
174
|
+
const KIND_EVALUATOR = 'evaluator';
|
|
175
|
+
const KIND_UNKNOWN = 'unknown';
|
|
176
|
+
|
|
177
|
+
const KNOWN_KINDS = new Set([KIND_LLM, KIND_TOOL, KIND_CHAIN, KIND_AGENT, KIND_RETRIEVER,
|
|
178
|
+
KIND_EMBEDDING, KIND_RERANKER, KIND_GUARDRAIL, KIND_EVALUATOR]);
|
|
179
|
+
|
|
180
|
+
const KIND_ALIASES = {
|
|
181
|
+
// OpenInference `openinference.span.kind`
|
|
182
|
+
llm: KIND_LLM, chain: KIND_CHAIN, tool: KIND_TOOL, agent: KIND_AGENT,
|
|
183
|
+
retriever: KIND_RETRIEVER, embedding: KIND_EMBEDDING, reranker: KIND_RERANKER,
|
|
184
|
+
guardrail: KIND_GUARDRAIL, evaluator: KIND_EVALUATOR,
|
|
185
|
+
// OTel GenAI `gen_ai.operation.name`
|
|
186
|
+
chat: KIND_LLM, text_completion: KIND_LLM, generate_content: KIND_LLM,
|
|
187
|
+
embeddings: KIND_EMBEDDING, execute_tool: KIND_TOOL, invoke_agent: KIND_AGENT,
|
|
188
|
+
create_agent: KIND_AGENT,
|
|
189
|
+
// Traceloop `traceloop.span.kind`
|
|
190
|
+
workflow: KIND_CHAIN, task: KIND_CHAIN, tool_call: KIND_TOOL,
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/** Any of these present means the span came from *some* GenAI instrumentation. The distinction
|
|
194
|
+
* matters: a span we cannot classify but which is plainly GenAI is a coverage failure that must be
|
|
195
|
+
* counted and surfaced, whereas a database span is simply not ours and deserves silence. */
|
|
196
|
+
const GENAI_MARKER_PREFIXES = ['gen_ai.', 'llm.', 'openinference.', 'traceloop.', 'openlit.'];
|
|
197
|
+
|
|
198
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
199
|
+
// Readers
|
|
200
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* `getattr` that cannot throw.
|
|
204
|
+
*
|
|
205
|
+
* Optional chaining only defends against the property being *absent*: whatever a getter throws
|
|
206
|
+
* still propagates. Every span here comes from instrumentation we do not own — a lazy `.name`, a
|
|
207
|
+
* `.status` computed from a closed response, a `.context` on a span the exporter already recycled —
|
|
208
|
+
* and any of those exploding would put an error on the host's request path from inside a telemetry
|
|
209
|
+
* reader. Degrade to the default instead; the field being unreadable is itself the only honest
|
|
210
|
+
* thing we can say about it.
|
|
211
|
+
*/
|
|
212
|
+
function get(obj, name, fallback) {
|
|
213
|
+
if (obj === null || obj === undefined) return fallback;
|
|
214
|
+
try {
|
|
215
|
+
const v = obj[name];
|
|
216
|
+
return v === undefined ? fallback : v;
|
|
217
|
+
} catch (_err) {
|
|
218
|
+
return fallback;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function first(attrs, keys) {
|
|
223
|
+
for (const k of keys) {
|
|
224
|
+
const v = attrs[k];
|
|
225
|
+
if (v !== null && v !== undefined && v !== '') return v;
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function toInt(v) {
|
|
231
|
+
if (v === null || v === undefined || typeof v === 'boolean') return null;
|
|
232
|
+
const n = typeof v === 'number' ? Math.trunc(v) : parseInt(String(v), 10);
|
|
233
|
+
return Number.isFinite(n) ? n : null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function toFloat(v) {
|
|
237
|
+
if (v === null || v === undefined || typeof v === 'boolean') return null;
|
|
238
|
+
const n = typeof v === 'number' ? v : parseFloat(String(v));
|
|
239
|
+
return Number.isFinite(n) ? n : null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Flatten whatever the convention put in a content attribute into a string.
|
|
244
|
+
*
|
|
245
|
+
* v1.42's `gen_ai.output.messages` is a JSON-ish structure, the legacy keys are plain strings, and
|
|
246
|
+
* OpenInference's `output.value` is either. Bounded, because an unbounded stringify of a
|
|
247
|
+
* conversation history is a memory event on somebody's request path.
|
|
248
|
+
*/
|
|
249
|
+
function toText(v, limit) {
|
|
250
|
+
const cap = limit === undefined ? 20000 : limit;
|
|
251
|
+
if (v === null || v === undefined) return null;
|
|
252
|
+
if (typeof v === 'string') return v.slice(0, cap) || null;
|
|
253
|
+
if (Array.isArray(v)) {
|
|
254
|
+
const parts = v.slice(0, 32).map((i) => toText(i, cap)).filter(Boolean);
|
|
255
|
+
return parts.join('\n').slice(0, cap) || null;
|
|
256
|
+
}
|
|
257
|
+
if (typeof v === 'object') {
|
|
258
|
+
for (const k of ['content', 'text', 'value', 'message']) {
|
|
259
|
+
if (k in v) return toText(v[k], cap);
|
|
260
|
+
}
|
|
261
|
+
try { return JSON.stringify(v).slice(0, cap) || null; } catch (_err) { return null; }
|
|
262
|
+
}
|
|
263
|
+
return String(v).slice(0, cap) || null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* What an identifier is allowed to look like. Deliberately a whitelist and deliberately narrow:
|
|
268
|
+
* call ids (`call_01H8XQ`, `toolu_01ABC`), dotted symbol paths, URNs and bare endpoint URLs all
|
|
269
|
+
* match; a sentence does not (spaces), an email does not (`@`), a query string does not (`?`, `=`),
|
|
270
|
+
* and neither does anything with a newline or a quote in it.
|
|
271
|
+
*
|
|
272
|
+
* The rule is not "this value is safe" — it is "a value that is not shaped like an identifier is
|
|
273
|
+
* not a target", which is a property of the field rather than a guess about the content.
|
|
274
|
+
*/
|
|
275
|
+
const IDENTIFIER_RE = /^[A-Za-z0-9_.:/-]{1,128}$/;
|
|
276
|
+
|
|
277
|
+
/** Bounds on {@link shapeOf}. A summary of an unbounded payload has to be bounded or it is a
|
|
278
|
+
* payload again by another route: 200 field names is not shape, it is data. */
|
|
279
|
+
const MAX_SHAPE_FIELDS = 16;
|
|
280
|
+
const MAX_SHAPE_DEPTH = 3;
|
|
281
|
+
const MAX_SHAPE_ITEMS = 32;
|
|
282
|
+
|
|
283
|
+
/** A field name is emitted only if it looks like a field name. Argument names normally come from
|
|
284
|
+
* the developer's own tool schema, which puts them in the same class as `toolName` — but a mapping
|
|
285
|
+
* keyed by customer data (an address book, a per-user index) would turn "names are safe" into the
|
|
286
|
+
* same leak wearing a different hat, so the shape of the *name* is checked too. */
|
|
287
|
+
const SAFE_KEY_RE = /^[A-Za-z_][A-Za-z0-9_.-]{0,39}$/;
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* `SAFE_KEY_RE` states the right requirement and then does not enforce it. It catches the two cases
|
|
291
|
+
* somebody pictured while writing it — a key that is an email address, a key that is bare digits —
|
|
292
|
+
* while `{"patient_90210443": 1, "mrn_44521": 2}` and `{"acme.customer.90210443": 1}` walk straight
|
|
293
|
+
* through. Those are not exotic: a per-patient index, a per-account rollup, a per-order dict is the
|
|
294
|
+
* ordinary shape of the payload an agent hands a tool, and the names in it *are* the identifiers.
|
|
295
|
+
*
|
|
296
|
+
* The distinction being drawn is between a *schema* name and an *instance* name. A schema name is
|
|
297
|
+
* written once by a developer and reused for every call; an instance name is minted per record and
|
|
298
|
+
* carries the record's identity. Nothing in the string says which — but identifiers are made of
|
|
299
|
+
* digit runs and schema names, being words, are not. So: a run of four or more digits, or six
|
|
300
|
+
* digits in total, means this is a value wearing a key's clothing.
|
|
301
|
+
*
|
|
302
|
+
* The threshold has a known and accepted cost. `iso8601` and `rfc3339` are legitimate schema names
|
|
303
|
+
* with a four-digit run, and they land in the anonymous bucket. That is the right side to be wrong
|
|
304
|
+
* on twice over: a false positive costs a field being reported by type instead of by name, while a
|
|
305
|
+
* false negative is a patient identifier on the wire at the tier that promises no content.
|
|
306
|
+
*/
|
|
307
|
+
const IDENTIFIERISH_KEY_RE = /\d{4}|(?:\D*\d){6}/;
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* `v` if it is shaped like an identifier, else `null`.
|
|
311
|
+
*
|
|
312
|
+
* Rejecting is the whole function. Every value here was written by instrumentation we do not own,
|
|
313
|
+
* into a key whose *convention* says "id" and whose contents are whatever the library felt like
|
|
314
|
+
* putting there. Truncating a payload to 128 characters would still emit a payload.
|
|
315
|
+
*
|
|
316
|
+
* Known residual, stated rather than papered over: a bare number passes, because numeric call ids
|
|
317
|
+
* are ordinary and a rule against them would break real instrumentation. So an instrumentation that
|
|
318
|
+
* wrote a national ID into `tool.id` would still be believed. That is a narrower hole than a
|
|
319
|
+
* sentence-shaped one, and the tier gate downstream is the layer that covers it.
|
|
320
|
+
*/
|
|
321
|
+
function identifier(v, limit) {
|
|
322
|
+
const cap = limit === undefined ? 128 : limit;
|
|
323
|
+
if (v === null || v === undefined || typeof v === 'boolean') return null;
|
|
324
|
+
if (typeof v !== 'string' && typeof v !== 'number') return null;
|
|
325
|
+
const s = String(v).trim();
|
|
326
|
+
if (!s || s.length > cap) return null;
|
|
327
|
+
return IDENTIFIER_RE.test(s) ? s : null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* A bounded, content-free description of a payload: types, counts, and safe field names.
|
|
332
|
+
*
|
|
333
|
+
* This is the representation `metadata_only` is supposed to emit, applied at the *reader* rather
|
|
334
|
+
* than at the gate. It answers the questions a ledger actually asks of a tool call — was it called
|
|
335
|
+
* with arguments, how many, which named parameters, were any of them structured — and it cannot
|
|
336
|
+
* answer "what did they say", because no value ever enters the result.
|
|
337
|
+
*
|
|
338
|
+
* Three deliberate omissions:
|
|
339
|
+
*
|
|
340
|
+
* - **No values, at any depth or type.** Numbers are values too: an account number, a dose, a
|
|
341
|
+
* salary. `{type: 'number'}` is the whole of what a number contributes here.
|
|
342
|
+
* - **No fingerprint.** `redactPreview` pairs `chars` with a hash because a paragraph of prose has
|
|
343
|
+
* the entropy to make a hash one-way. Tool arguments frequently do not — a boolean, a zip code,
|
|
344
|
+
* a member of a five-value enum is recoverable from its digest by trying every input — so a
|
|
345
|
+
* fingerprint here would be a correlation handle over customer content wearing a safe costume.
|
|
346
|
+
* - **No free-form key names.** See {@link SAFE_KEY_RE}.
|
|
347
|
+
*/
|
|
348
|
+
function shapeOf(v, depth) {
|
|
349
|
+
const d = depth || 0;
|
|
350
|
+
if (d > MAX_SHAPE_DEPTH) return { type: '...' };
|
|
351
|
+
if (v === null || v === undefined) return { type: 'null' };
|
|
352
|
+
if (typeof v === 'boolean') return { type: 'bool' };
|
|
353
|
+
if (typeof v === 'number' || typeof v === 'bigint') return { type: 'number' };
|
|
354
|
+
if (typeof v === 'string') {
|
|
355
|
+
const s = v.trim();
|
|
356
|
+
if ((s.startsWith('{') || s.startsWith('[')) && d < MAX_SHAPE_DEPTH) {
|
|
357
|
+
let parsed = null;
|
|
358
|
+
try { parsed = JSON.parse(s); } catch (_err) { parsed = null; }
|
|
359
|
+
if (parsed !== null && typeof parsed === 'object') {
|
|
360
|
+
return Object.assign({}, shapeOf(parsed, d) || {}, { json: true });
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// `chars` on a string is retained: it is exactly the shape signal the tier ladder already
|
|
364
|
+
// emits at T0, so it is inside the tier's existing contract rather than an extension of it.
|
|
365
|
+
return { type: 'string', chars: v.length };
|
|
366
|
+
}
|
|
367
|
+
if (Array.isArray(v) || v instanceof Set) {
|
|
368
|
+
const all = Array.isArray(v) ? v : Array.from(v);
|
|
369
|
+
const items = all.slice(0, MAX_SHAPE_ITEMS);
|
|
370
|
+
const types = [...new Set(items.map((i) => (shapeOf(i, d + 1) || {}).type || '?'))].sort();
|
|
371
|
+
return { type: 'array', count: all.length, item_types: types };
|
|
372
|
+
}
|
|
373
|
+
if (typeof v === 'object') {
|
|
374
|
+
const keys = Object.keys(v);
|
|
375
|
+
const out = { type: 'object', field_count: keys.length };
|
|
376
|
+
const fields = {};
|
|
377
|
+
let unnamed = 0;
|
|
378
|
+
let omitted = 0;
|
|
379
|
+
const unnamedTypes = [];
|
|
380
|
+
for (const key of keys) {
|
|
381
|
+
const name = String(key);
|
|
382
|
+
if (!SAFE_KEY_RE.test(name) || IDENTIFIERISH_KEY_RE.test(name)) {
|
|
383
|
+
unnamed += 1;
|
|
384
|
+
// A rejected name is not a rejected field. Dropping the entry outright would make a payload
|
|
385
|
+
// of eight per-patient numbers indistinguishable from an empty object — both less useful
|
|
386
|
+
// and less honest than saying there were eight numbers whose names could not be shown.
|
|
387
|
+
// Reported as a sibling list rather than as synthesised `field_0` entries, because
|
|
388
|
+
// `field_0` is itself a name `SAFE_KEY_RE` admits, and a real key spelled that way would
|
|
389
|
+
// then silently collide with a placeholder.
|
|
390
|
+
if (unnamedTypes.length < MAX_SHAPE_FIELDS) {
|
|
391
|
+
unnamedTypes.push((shapeOf(v[key], d + 1) || {}).type || '?');
|
|
392
|
+
}
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
if (Object.keys(fields).length >= MAX_SHAPE_FIELDS) { omitted += 1; continue; }
|
|
396
|
+
fields[name] = shapeOf(v[key], d + 1);
|
|
397
|
+
}
|
|
398
|
+
out.fields = fields;
|
|
399
|
+
if (unnamed) { out.fields_unnamed = unnamed; out.unnamed_types = unnamedTypes.slice().sort(); }
|
|
400
|
+
if (omitted) out.fields_omitted = omitted;
|
|
401
|
+
return out;
|
|
402
|
+
}
|
|
403
|
+
return { type: typeof v };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* The shape fragment for a tool span: its arguments, plus any target we refused.
|
|
408
|
+
*
|
|
409
|
+
* A refused target is reported rather than dropped in silence. "This span had a target-shaped key
|
|
410
|
+
* holding 4 kB of string" is a fact about somebody's instrumentation that the reader of the ledger
|
|
411
|
+
* should be able to see; leaving it out would make a leak-prevention step look identical to an
|
|
412
|
+
* attribute that was never there.
|
|
413
|
+
*/
|
|
414
|
+
function toolArgShape(attrs, rejectedTarget) {
|
|
415
|
+
let frag = {};
|
|
416
|
+
const payload = first(attrs, TOOL_ARG_KEYS);
|
|
417
|
+
if (payload !== null && payload !== undefined) {
|
|
418
|
+
const shape = shapeOf(payload);
|
|
419
|
+
if (shape) frag = Object.assign({}, shape);
|
|
420
|
+
}
|
|
421
|
+
if (rejectedTarget !== null && rejectedTarget !== undefined) {
|
|
422
|
+
const rejected = shapeOf(rejectedTarget);
|
|
423
|
+
if (rejected) frag.target = rejected;
|
|
424
|
+
}
|
|
425
|
+
return Object.keys(frag).length ? frag : null;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function hex(v) {
|
|
429
|
+
if (v === null || v === undefined) return '';
|
|
430
|
+
if (typeof v === 'number') return v.toString(16).padStart(32, '0');
|
|
431
|
+
if (typeof v === 'bigint') return v.toString(16).padStart(32, '0');
|
|
432
|
+
return String(v);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
let anonCounter = 0;
|
|
436
|
+
const anonIds = new WeakMap();
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* `[traceId, spanId, parentSpanId]` from any span-shaped object.
|
|
440
|
+
*
|
|
441
|
+
* Structural rather than typed: a real `ReadableSpan` exposes `.context`, an in-flight `Span`
|
|
442
|
+
* exposes `.spanContext()`, and neither can be named here without importing an OpenTelemetry
|
|
443
|
+
* package into a zero-dependency core.
|
|
444
|
+
*/
|
|
445
|
+
function spanIds(span) {
|
|
446
|
+
let ctx = get(span, 'context');
|
|
447
|
+
if (ctx === undefined || ctx === null) {
|
|
448
|
+
// The JS SDK spells it `spanContext()`; the Python one `get_span_context()`. Both are tried.
|
|
449
|
+
for (const getterName of ['spanContext', 'get_span_context']) {
|
|
450
|
+
const getter = get(span, getterName);
|
|
451
|
+
if (typeof getter === 'function') {
|
|
452
|
+
try { ctx = getter.call(span); break; } catch (_err) { ctx = null; }
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
const traceId = ctx ? hex(get(ctx, 'traceId', get(ctx, 'trace_id'))) : '';
|
|
457
|
+
let spanId = ctx ? hex(get(ctx, 'spanId', get(ctx, 'span_id'))) : '';
|
|
458
|
+
const parent = get(span, 'parentSpanContext', get(span, 'parent'));
|
|
459
|
+
let parentId = null;
|
|
460
|
+
if (parent !== null && parent !== undefined) {
|
|
461
|
+
const pid = get(parent, 'spanId', get(parent, 'span_id', parent));
|
|
462
|
+
parentId = pid === null || pid === undefined ? null : hex(pid);
|
|
463
|
+
}
|
|
464
|
+
if (!spanId) {
|
|
465
|
+
// A WeakMap rather than an object identity hash: JavaScript has no `id()`, and a monotonic
|
|
466
|
+
// counter alone would give one span two ids if it were read twice. Weak so an anonymous span
|
|
467
|
+
// does not keep itself alive through this table.
|
|
468
|
+
if (typeof span === 'object' && span !== null) {
|
|
469
|
+
if (!anonIds.has(span)) { anonCounter += 1; anonIds.set(span, 'anon-' + anonCounter); }
|
|
470
|
+
spanId = anonIds.get(span);
|
|
471
|
+
} else {
|
|
472
|
+
anonCounter += 1;
|
|
473
|
+
spanId = 'anon-' + anonCounter;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return [traceId, spanId, parentId || null];
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function scopeName(span) {
|
|
480
|
+
for (const attr of ['instrumentationScope', 'instrumentation_scope', 'instrumentationLibrary',
|
|
481
|
+
'instrumentation_info']) {
|
|
482
|
+
const scope = get(span, attr);
|
|
483
|
+
const name = get(scope, 'name');
|
|
484
|
+
if (name) return String(name);
|
|
485
|
+
}
|
|
486
|
+
return String(get(span, 'scope', '') || '');
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function kindAndVocabulary(attrs) {
|
|
490
|
+
const raw = first(attrs, KIND_KEYS);
|
|
491
|
+
let vocab = 'unknown';
|
|
492
|
+
const keys = Object.keys(attrs);
|
|
493
|
+
if (attrs['openinference.span.kind'] !== undefined && attrs['openinference.span.kind'] !== null) {
|
|
494
|
+
vocab = 'openinference';
|
|
495
|
+
} else if (attrs['traceloop.span.kind'] !== undefined && attrs['traceloop.span.kind'] !== null) {
|
|
496
|
+
vocab = 'openllmetry';
|
|
497
|
+
} else if (keys.some((k) => String(k).startsWith('gen_ai.'))) {
|
|
498
|
+
vocab = 'genai';
|
|
499
|
+
} else if (keys.some((k) => String(k).startsWith('llm.'))) {
|
|
500
|
+
vocab = 'openinference';
|
|
501
|
+
}
|
|
502
|
+
if (raw === null) {
|
|
503
|
+
// No explicit kind. Infer from the token/model attributes rather than give up: several shipped
|
|
504
|
+
// instrumentations emit usage on an unlabelled span, and dropping those would mean losing the
|
|
505
|
+
// numbers we exist to record.
|
|
506
|
+
if (first(attrs, INPUT_TOKEN_KEYS) !== null || first(attrs, RESPONSE_MODEL_KEYS) !== null) {
|
|
507
|
+
return [KIND_LLM, vocab];
|
|
508
|
+
}
|
|
509
|
+
if (first(attrs, TOOL_NAME_KEYS) !== null) return [KIND_TOOL, vocab];
|
|
510
|
+
return [KIND_UNKNOWN, vocab];
|
|
511
|
+
}
|
|
512
|
+
return [KIND_ALIASES[String(raw).trim().toLowerCase()] || KIND_UNKNOWN, vocab];
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function errorOf(span) {
|
|
516
|
+
const status = get(span, 'status');
|
|
517
|
+
const code = get(status, 'code', get(status, 'status_code'));
|
|
518
|
+
// The JS SDK uses a numeric `SpanStatusCode` where `2 === ERROR`; the Python one an enum whose
|
|
519
|
+
// `.name` is the string. Both are read, because a bridge that only understood one would report
|
|
520
|
+
// every error as a success against the other half of the ecosystem.
|
|
521
|
+
const name = get(code, 'name') || String(code === undefined || code === null ? '' : code);
|
|
522
|
+
const isError = String(name).toUpperCase().includes('ERROR') || code === 2;
|
|
523
|
+
if (!isError) return null;
|
|
524
|
+
const desc = get(status, 'message', get(status, 'description'));
|
|
525
|
+
return String(desc || 'error');
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function durationMs(span) {
|
|
529
|
+
const start = get(span, 'startTime', get(span, 'start_time'));
|
|
530
|
+
const end = get(span, 'endTime', get(span, 'end_time'));
|
|
531
|
+
// Python's SDK uses integer nanoseconds; the JS SDK uses `[seconds, nanos]` tuples.
|
|
532
|
+
const hr = (v) => (Array.isArray(v) && v.length === 2 ? v[0] * 1e9 + v[1] : v);
|
|
533
|
+
const s = hr(start);
|
|
534
|
+
const e = hr(end);
|
|
535
|
+
if (typeof s === 'number' && typeof e === 'number' && e >= s) return Math.trunc((e - s) / 1e6);
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Everything the ledger needs from a span, and nothing it does not. */
|
|
540
|
+
function emptyFacts() {
|
|
541
|
+
return {
|
|
542
|
+
traceId: '', spanId: '', parentSpanId: null, name: '', scope: '',
|
|
543
|
+
kind: KIND_UNKNOWN, vocabulary: 'unknown', isGenai: false, isHttp: false,
|
|
544
|
+
provider: null, model: null,
|
|
545
|
+
inputTokens: null, outputTokens: null, cacheReadTokens: null,
|
|
546
|
+
cacheWrite5mTokens: null, cacheWrite1hTokens: null,
|
|
547
|
+
reportedCostUsd: null, attempts: null,
|
|
548
|
+
inputText: null, outputText: null, reasoningText: null,
|
|
549
|
+
toolName: null, toolTarget: null, toolArgShape: null, agentName: null,
|
|
550
|
+
error: null, durationMs: null, attributes: {},
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** True when this observation carries billable numbers, not merely structure. */
|
|
555
|
+
function hasUsage(facts) {
|
|
556
|
+
return Boolean(facts.inputTokens || facts.outputTokens || facts.cacheReadTokens
|
|
557
|
+
|| facts.cacheWrite5mTokens || facts.cacheWrite1hTokens);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* The breakdown in `pricing.costFromUsage`'s own field names.
|
|
562
|
+
*
|
|
563
|
+
* Deliberately the *transcript's* vocabulary rather than ours, so the parity check against the
|
|
564
|
+
* Python rate card compares like with like instead of comparing two translations.
|
|
565
|
+
*/
|
|
566
|
+
function usageDict(facts) {
|
|
567
|
+
const u = {
|
|
568
|
+
input_tokens: facts.inputTokens || 0,
|
|
569
|
+
output_tokens: facts.outputTokens || 0,
|
|
570
|
+
cache_read_input_tokens: facts.cacheReadTokens || 0,
|
|
571
|
+
};
|
|
572
|
+
if (facts.cacheWrite1hTokens) {
|
|
573
|
+
u.cache_creation = {
|
|
574
|
+
ephemeral_5m_input_tokens: facts.cacheWrite5mTokens || 0,
|
|
575
|
+
ephemeral_1h_input_tokens: facts.cacheWrite1hTokens,
|
|
576
|
+
};
|
|
577
|
+
} else if (facts.cacheWrite5mTokens) {
|
|
578
|
+
u.cache_creation_input_tokens = facts.cacheWrite5mTokens;
|
|
579
|
+
}
|
|
580
|
+
return u;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Read a span into facts. Never throws — a malformed span yields empty facts. */
|
|
584
|
+
function normalise(span) {
|
|
585
|
+
let attrs;
|
|
586
|
+
try {
|
|
587
|
+
const raw = get(span, 'attributes') || {};
|
|
588
|
+
attrs = typeof raw === 'object' && raw !== null ? Object.assign({}, raw) : {};
|
|
589
|
+
} catch (_err) {
|
|
590
|
+
attrs = {};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const facts = emptyFacts();
|
|
594
|
+
try {
|
|
595
|
+
const [traceId, spanId, parentId] = spanIds(span);
|
|
596
|
+
const [kind, vocab] = kindAndVocabulary(attrs);
|
|
597
|
+
const keys = Object.keys(attrs);
|
|
598
|
+
const model = first(attrs, RESPONSE_MODEL_KEYS) || first(attrs, REQUEST_MODEL_KEYS);
|
|
599
|
+
const rawTarget = first(attrs, TOOL_TARGET_KEYS);
|
|
600
|
+
const target = identifier(rawTarget);
|
|
601
|
+
const provider = first(attrs, PROVIDER_KEYS);
|
|
602
|
+
const toolName = first(attrs, TOOL_NAME_KEYS);
|
|
603
|
+
const agentName = first(attrs, AGENT_NAME_KEYS);
|
|
604
|
+
|
|
605
|
+
facts.traceId = traceId;
|
|
606
|
+
facts.spanId = spanId;
|
|
607
|
+
facts.parentSpanId = parentId;
|
|
608
|
+
facts.name = String(get(span, 'name', '') || '');
|
|
609
|
+
facts.scope = scopeName(span);
|
|
610
|
+
facts.kind = kind;
|
|
611
|
+
facts.vocabulary = vocab;
|
|
612
|
+
facts.isGenai = keys.some((k) => GENAI_MARKER_PREFIXES.some((p) => String(k).startsWith(p)))
|
|
613
|
+
|| kind !== KIND_UNKNOWN;
|
|
614
|
+
facts.isHttp = HTTP_MARKERS.some((k) => k in attrs);
|
|
615
|
+
facts.provider = provider ? String(provider) : null;
|
|
616
|
+
facts.model = model ? String(model) : null;
|
|
617
|
+
facts.inputTokens = toInt(first(attrs, INPUT_TOKEN_KEYS));
|
|
618
|
+
facts.outputTokens = toInt(first(attrs, OUTPUT_TOKEN_KEYS));
|
|
619
|
+
facts.cacheReadTokens = toInt(first(attrs, CACHE_READ_KEYS));
|
|
620
|
+
facts.cacheWrite5mTokens = toInt(first(attrs, CACHE_WRITE_5M_KEYS));
|
|
621
|
+
facts.cacheWrite1hTokens = toInt(first(attrs, CACHE_WRITE_1H_KEYS));
|
|
622
|
+
facts.reportedCostUsd = toFloat(first(attrs, REPORTED_COST_KEYS));
|
|
623
|
+
facts.attempts = toInt(first(attrs, ATTEMPT_KEYS));
|
|
624
|
+
facts.inputText = toText(first(attrs, INPUT_TEXT_KEYS));
|
|
625
|
+
facts.outputText = toText(first(attrs, OUTPUT_TEXT_KEYS));
|
|
626
|
+
facts.reasoningText = toText(first(attrs, REASONING_KEYS));
|
|
627
|
+
facts.toolName = toolName ? String(toolName) : null;
|
|
628
|
+
facts.toolTarget = target;
|
|
629
|
+
facts.toolArgShape = toolArgShape(attrs, target === null ? rawTarget : null);
|
|
630
|
+
facts.agentName = agentName ? String(agentName) : null;
|
|
631
|
+
facts.error = errorOf(span);
|
|
632
|
+
facts.durationMs = durationMs(span);
|
|
633
|
+
facts.attributes = attrs;
|
|
634
|
+
} catch (_err) {
|
|
635
|
+
// A reader that raises into the host is worse than one that reports nothing.
|
|
636
|
+
return facts;
|
|
637
|
+
}
|
|
638
|
+
return facts;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
module.exports = {
|
|
642
|
+
GENAI_SEMCONV_VERSION, GENAI_SEMCONV_STABILITY,
|
|
643
|
+
KIND_LLM, KIND_TOOL, KIND_CHAIN, KIND_AGENT, KIND_RETRIEVER, KIND_EMBEDDING,
|
|
644
|
+
KIND_RERANKER, KIND_GUARDRAIL, KIND_EVALUATOR, KIND_UNKNOWN, KNOWN_KINDS,
|
|
645
|
+
MAX_SHAPE_FIELDS, MAX_SHAPE_DEPTH, MAX_SHAPE_ITEMS,
|
|
646
|
+
normalise, hasUsage, usageDict, shapeOf, identifier, emptyFacts,
|
|
647
|
+
// Exported for tests that assert on the vocabulary rather than on one example.
|
|
648
|
+
INPUT_TOKEN_KEYS, OUTPUT_TOKEN_KEYS, CACHE_READ_KEYS, CACHE_WRITE_5M_KEYS, CACHE_WRITE_1H_KEYS,
|
|
649
|
+
TOOL_TARGET_KEYS, TOOL_ARG_KEYS, IDENTIFIER_RE, SAFE_KEY_RE, IDENTIFIERISH_KEY_RE,
|
|
650
|
+
};
|